From 8f6a8c87185ec3c2b4c5b68e5211cd66b53e8a0d Mon Sep 17 00:00:00 2001 From: shiffa-04 Date: Sat, 13 Dec 2025 20:17:33 +0530 Subject: [PATCH 1/8] docs: add local API testing instructions --- .gitignore | 3 +++ README.md | 22 +++++++++++++++++++++ docker-compose.yml | 7 +++++-- scripts/generate_jwt_token.js | 37 +++++++++++++++++++++++++++++++++++ scripts/package.json | 5 +++++ 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 scripts/generate_jwt_token.js create mode 100644 scripts/package.json diff --git a/.gitignore b/.gitignore index f85717c4..548d5ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,6 @@ test.docker-compose.yml fluvio-data/ fluvio-metadata/ mongo-data/ + +#Deployment files +deployment.yaml \ No newline at end of file diff --git a/README.md b/README.md index 5ec9219a..3376b4d3 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,28 @@ To stop the docker containers, simply run: pica stop ``` +### Testing API Locally + +To test the API locally, a JWT token is required. Ensure the `connections-api` service is built and running with the latest changes before generating the token. + +1. Build and run the `connections-api` service: + ```bash + docker compose build connections-api + docker compose up connections-api + ``` + +2. Install the required dependencies: + ```bash + cd scripts + npm install + ``` + +3. Generate the token: + ```bash + node generate_jwt_token.js + ``` + + ## License diff --git a/docker-compose.yml b/docker-compose.yml index 4f27d266..ce1d6ccb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,10 @@ services: connections-api: platform: linux/amd64 - image: us-docker.pkg.dev/integrationos/docker-oss/api:1.35.0 + build: + context: . + dockerfile: api/Dockerfile + # image: us-docker.pkg.dev/integrationos/docker-oss/api:1.35.0 ports: - 3005:3005 environment: @@ -105,4 +108,4 @@ services: - MONGO_INITDB_ROOT_USERNAME=pica redis: - image: redis:7.0 + image: redis:7.0 \ No newline at end of file diff --git a/scripts/generate_jwt_token.js b/scripts/generate_jwt_token.js new file mode 100644 index 00000000..8f358eca --- /dev/null +++ b/scripts/generate_jwt_token.js @@ -0,0 +1,37 @@ +// Save as generate-jwt.js +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = 'Qsfb9YUkdjwUULX.u96HdTCX4q7GuB'; + +// Match the Claims struct expected by your Rust code +const payload = { + // Required field that's missing in your current token + _id: "65648fa26b1eb500122c5323", // Or whatever user ID format your system expects + + // Standard JWT fields + sub: "65648fa26b1eb500122c5323", + exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24), // Expires in 24 hours + iat: Math.floor(Date.now() / 1000), // Issued at time + + // Additional fields that might be required + // role: "admin" + email: "dev@integrationos.com", + username: "integrationos-dev", + userKey: "integrationos-dev522eb2", + firstName: "IntegrationOS", + lastName: "Developer", + buildableId: "build-1c3cd7af757d4aebab523f5373190e1b", + containerId: "", + pointers: [ + "_1_3pejYG_SdSxV9xkt5_GA8WoMsSnfBHvY1qpGhlX-6DKd9kyZO3ee9hWfjGWpt5dY0AzxvM51q6_45_Q6bJTWCTuax7yq4X96nhvB0uTwhhLlsxyJm02JqasmdeDVeHt08GxGPoiBc7I9u00-1EKOejw62kNO0M1EaEFqwaGXw1Y8IfFH", + "_1_hUOSWuG8lfzaWIvyA4NLf3YuuFIF_4oCzEF0nuKDiqyh0IA9yhIqcrkeBOsg8AhY509EdqufSPWEvuNpwib4puQLEbrJM55H2pSgHcFji-TLPT5HvqA24TNCpJcd70oAfgLsIAqmqmM8EJVyJQaa44stNUBWF6Ahg47P1KcFwFAJ0I_O" + ], + isBuildableCore: false, + aud: "pica-users", + iss: "pica" +}; + +// const compositeSecret = JWT_SECRET + payload.buildableId; +const token = jwt.sign(payload, JWT_SECRET); +// const token = jwt.sign(payload, JWT_SECRET); +console.log(token); \ No newline at end of file diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 00000000..256d43d2 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "jsonwebtoken": "^9.0.3" + } +} From 38ec67826d681422c8166247efa59067a8bec972 Mon Sep 17 00:00:00 2001 From: shiffa-04 Date: Mon, 15 Dec 2025 16:18:58 +0530 Subject: [PATCH 2/8] fix: Implement hard delete for connections --- api/src/logic/connection_definition.rs | 99 +++++++++++++++++++- api/src/logic/connection_model_definition.rs | 9 +- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/api/src/logic/connection_definition.rs b/api/src/logic/connection_definition.rs index e547b752..e3f14def 100644 --- a/api/src/logic/connection_definition.rs +++ b/api/src/logic/connection_definition.rs @@ -1,4 +1,6 @@ -use super::{create, delete, read, update, HookExt, PublicExt, ReadResponse, RequestExt}; +use super::{ + create, read, update, HookExt, PublicExt, ReadResponse, RequestExt, SuccessResponse, +}; use crate::{ helper::shape_mongo_filter, router::ServerResponse, @@ -43,8 +45,101 @@ pub fn get_router() -> Router> { .route( "/:id", patch(update::) - .delete(delete::), + .delete(delete_by_connection_definition_id), + ) +} + +pub async fn delete_by_connection_definition_id( + Path(id): Path, + State(state): State>, +) -> Result>, PicaError> { + let stores = &state.app_stores; + + // 1. ConnectionDefinition + stores + .connection_config + .collection + .delete_one( + doc! { + "_id": &id + }, + ) + .await + .map_err(|e| { + error!("Error deleting connection definition: {e}"); + e + })?; + + // 2. ConnectionModelDefinition + stores + .model_config + .collection + .delete_many( + doc! { + "connectionDefinitionId": &id + }, + ) + .await + .map_err(|e| { + error!("Error deleting connection model definitions: {e}"); + e + })?; + + // 3. ConnectionModelSchema + stores + .model_schema + .collection + .delete_many( + doc! { + "connectionDefinitionId": &id + }, ) + .await + .map_err(|e| { + error!("Error deleting connection model schemas: {e}"); + e + })?; + + // 4. PlatformData + stores + .platform + .collection + .delete_many( + doc! { + "connectionDefinitionId": &id + }, + ) + .await + .map_err(|e| { + error!("Error deleting platform data: {e}"); + e + })?; + + // 5. Settings + stores + .settings + .update_many( + doc! { + "connectedPlatforms.connectionDefinitionId": &id + }, + doc! { + "$pull": { + "connectedPlatforms": { + "connectionDefinitionId": &id + } + } + }, + ) + .await + .map_err(|e| { + error!("Error updating settings: {e}"); + e + })?; + + Ok(Json(ServerResponse::new( + "delete", + SuccessResponse { success: true }, + ))) } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Dummy)] diff --git a/api/src/logic/connection_model_definition.rs b/api/src/logic/connection_model_definition.rs index 8b78ae7c..3b19bb30 100644 --- a/api/src/logic/connection_model_definition.rs +++ b/api/src/logic/connection_model_definition.rs @@ -1,4 +1,6 @@ -use super::{create, delete, read, update, HookExt, PublicExt, ReadResponse, RequestExt}; +use super::{ + create, delete, read, update, HookExt, PublicExt, ReadResponse, RequestExt, +}; use crate::{ helper::shape_mongo_filter, router::ServerResponse, @@ -19,12 +21,16 @@ use osentities::{ api_model_config::{ ApiModelConfig, AuthMethod, ModelPaths, ResponseBody, SamplesInput, SchemasInput, }, + connection_definition::ConnectionDefinition, connection_model_definition::{ ConnectionModelDefinition, CrudAction, CrudMapping, ExtractorConfig, PlatformInfo, TestConnection, TestConnectionState, }, + connection_model_schema::ConnectionModelSchema, + connection_oauth_definition::Settings, event_access::EventAccess, id::{prefix::IdPrefix, Id}, + platform::PlatformData, ApplicationError, InternalError, PicaError, }; use semver::Version; @@ -272,6 +278,7 @@ pub async fn test_connection_model_definition( ))) } + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Dummy)] #[serde(rename_all = "camelCase")] pub struct CreateRequest { From 7e6de2faa24fa9cd34445270610e6564c55bff6a Mon Sep 17 00:00:00 2001 From: Aadil-Hasun Date: Fri, 11 Apr 2025 11:56:32 +0530 Subject: [PATCH 3/8] update --- .gitignore | 5 +- api/src/logic/connection_variable_mapping.rs | 180 ++++++++++++++++++ api/src/logic/mod.rs | 1 + api/src/router/secured_key.rs | 5 + api/src/server.rs | 5 + .../connection/connection_variable_mapping.rs | 142 ++++++++++++++ osentities/src/domain/connection/mod.rs | 1 + osentities/src/domain/id/prefix.rs | 4 + osentities/src/domain/store/mod.rs | 4 +- unified/src/helper/mod.rs | 4 +- unified/src/unified.rs | 160 +++++++++++++++- 11 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 api/src/logic/connection_variable_mapping.rs create mode 100644 osentities/src/domain/connection/connection_variable_mapping.rs diff --git a/.gitignore b/.gitignore index 548d5ac5..ee9680a1 100644 --- a/.gitignore +++ b/.gitignore @@ -214,4 +214,7 @@ fluvio-metadata/ mongo-data/ #Deployment files -deployment.yaml \ No newline at end of file +deployment.yaml + +# Scripts +*.py \ No newline at end of file diff --git a/api/src/logic/connection_variable_mapping.rs b/api/src/logic/connection_variable_mapping.rs new file mode 100644 index 00000000..01a1b7d6 --- /dev/null +++ b/api/src/logic/connection_variable_mapping.rs @@ -0,0 +1,180 @@ +use super::{delete, read, update, HookExt, PublicExt, RequestExt}; +use crate::{ + router::ServerResponse, + server::{AppState, AppStores}, +}; +use axum::{ + extract::{State, Json}, + http::StatusCode, + response::IntoResponse, + routing::{patch, post}, + Extension, Router, +}; +use bson::doc; +use chrono::Utc; +use osentities::{ + algebra::MongoStore, + connection_variable_mapping::{ + ConnectionVariableMapping, InjectionStrategy, ParameterLocation, VariableBinding, + VariableDataType, + }, + event_access::EventAccess, + id::{prefix::IdPrefix, Id}, + record_metadata::RecordMetadata, + ApplicationError, InternalError, PicaError, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub fn get_router() -> Router> { + Router::new() + .route( + "/", + post(create_mapping) + .get(read::), + ) + .route( + "/:id", + patch(update::) + .delete(delete::), + ) +} + +async fn create_mapping( + State(state): State>, + Extension(access): Extension>, + Json(payload): Json, +) -> Result { + let stores = &state.app_stores; + // Check if mapping already exists for this definition + let mut filter = doc! { + "connectionModelDefinitionId": payload.connection_model_definition_id.to_string(), + "deleted": false, + }; + filter.insert("ownership.buildableId", access.ownership.id.to_string()); + + let existing = stores + .connection_variable_mapping + .get_many( + Some(filter), + None, + None, + Some(1), // Limit 1 + None, + ) + .await + .map_err(PicaError::from)?; + + if !existing.is_empty() { + return Err(ApplicationError::conflict( + &format!( + "Mapping already exists for model definition {}", + payload.connection_model_definition_id + ), + None, + ).into()); + } + + // Proceed with creation using standard logic + let record = payload.access(access.clone()).ok_or_else(|| { + InternalError::unknown("Failed to create record from request", None) + })?; + + let created = stores + .connection_variable_mapping + .create_one(&record) + .await + .map_err(PicaError::from)?; + + Ok((StatusCode::CREATED, Json(ServerResponse::new("create", CreateRequest::public(record))))) +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRequest { + #[serde(rename = "_id")] + pub id: Option, + + /// The model definition this mapping applies to (Platform Level) + pub connection_model_definition_id: Id, + + /// List of variable-to-parameter bindings + pub bindings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BindingRequest { + /// Name of the variable in secrets (e.g., "hotel_id", "SALESFORCE_DOMAIN") + pub variable_name: String, + + /// Target parameter name in the API call (e.g., "id", "domain") + pub target_param: String, + + /// Where to inject the value + pub location: ParameterLocation, + + /// How to inject the value + #[serde(default)] + pub strategy: InjectionStrategy, + + /// Data type of the variable (for conversion) + #[serde(default)] + pub data_type: VariableDataType, +} + +impl HookExt for CreateRequest {} +impl PublicExt for CreateRequest {} + +impl RequestExt for CreateRequest { + type Output = ConnectionVariableMapping; + + fn access(&self, event_access: Arc) -> Option { + Some(Self::Output { + id: self + .id + .unwrap_or_else(|| Id::now(IdPrefix::ConnectionVariableMapping)), + connection_model_definition_id: self.connection_model_definition_id, + bindings: self + .bindings + .iter() + .map(|b| VariableBinding { + variable_name: b.variable_name.clone(), + target_param: b.target_param.clone(), + location: b.location.clone(), + strategy: b.strategy.clone(), + data_type: b.data_type.clone(), + }) + .collect(), + ownership: event_access.ownership.clone(), + environment: event_access.environment.clone(), + record_metadata: RecordMetadata::default(), + }) + } + + // from() returns None because this endpoint requires authentication + // and we need ownership from EventAccess + + fn update(&self, mut record: Self::Output) -> Self::Output { + record.connection_model_definition_id = self.connection_model_definition_id; + record.bindings = self + .bindings + .iter() + .map(|b| VariableBinding { + variable_name: b.variable_name.clone(), + target_param: b.target_param.clone(), + location: b.location.clone(), + strategy: b.strategy.clone(), + data_type: b.data_type.clone(), + }) + .collect(); + record.record_metadata.updated_at = Utc::now().timestamp_millis(); + record.record_metadata.updated = true; + + record + } + + fn get_store(stores: AppStores) -> MongoStore { + stores.connection_variable_mapping.clone() + } +} diff --git a/api/src/logic/mod.rs b/api/src/logic/mod.rs index 9aaeca45..5bc1f0b8 100644 --- a/api/src/logic/mod.rs +++ b/api/src/logic/mod.rs @@ -28,6 +28,7 @@ pub mod connection_definition; pub mod connection_model_definition; pub mod connection_model_schema; pub mod connection_oauth_definition; +pub mod connection_variable_mapping; pub mod event_access; pub mod event_callback; pub mod events; diff --git a/api/src/router/secured_key.rs b/api/src/router/secured_key.rs index 5062936a..a16d7832 100644 --- a/api/src/router/secured_key.rs +++ b/api/src/router/secured_key.rs @@ -5,6 +5,7 @@ use crate::{ connection_model_schema::{ public_get_connection_model_schema, PublicGetConnectionModelSchema, }, + connection_variable_mapping, event_access, events, knowledge, metrics, oauth, passthrough, secrets, tasks, unified, vault_connection, }, @@ -45,6 +46,10 @@ pub async fn get_router(state: &Arc) -> Router> { .nest("/secrets", secrets::get_router()) .nest("/unified", unified::get_router()) .nest("/vault/connections", vault_connection::get_router()) + .nest( + "/connection-variable-mappings", + connection_variable_mapping::get_router(), + ) .route( "/connection-model-definitions/test/:id", post(test_connection_model_definition), diff --git a/api/src/server.rs b/api/src/server.rs index 29f4d5e4..5ab421c4 100644 --- a/api/src/server.rs +++ b/api/src/server.rs @@ -24,6 +24,7 @@ use osentities::{ connection_model_definition::ConnectionModelDefinition, connection_model_schema::{ConnectionModelSchema, PublicConnectionModelSchema}, connection_oauth_definition::{ConnectionOAuthDefinition, Settings}, + connection_variable_mapping::ConnectionVariableMapping, event_access::EventAccess, page::PlatformPage, secret::Secret, @@ -60,6 +61,7 @@ pub struct AppStores { pub secrets: MongoStore, pub settings: MongoStore, pub tasks: MongoStore, + pub connection_variable_mapping: MongoStore, } #[derive(Clone)] @@ -118,6 +120,8 @@ impl Server { let clients = MongoStore::new(&db, &Store::Clients).await?; let secrets_store = MongoStore::::new(&db, &Store::Secrets).await?; let tasks = MongoStore::new(&db, &Store::Tasks).await?; + let connection_variable_mapping = + MongoStore::new(&db, &Store::ConnectionVariableMappings).await?; let secrets_client: Arc = match config.secrets_config.provider { @@ -177,6 +181,7 @@ impl Server { event, clients, tasks, + connection_variable_mapping, }; let event_access_cache = diff --git a/osentities/src/domain/connection/connection_variable_mapping.rs b/osentities/src/domain/connection/connection_variable_mapping.rs new file mode 100644 index 00000000..bdc1fc9c --- /dev/null +++ b/osentities/src/domain/connection/connection_variable_mapping.rs @@ -0,0 +1,142 @@ +use crate::{ + id::Id, + prelude::shared::{ + ownership::Ownership, + record_metadata::RecordMetadata, + }, + configuration::environment::Environment, +}; +use serde::{Deserialize, Serialize}; + +/// Mapping between connection variables and model definition parameters. +/// Defines how per-connection variables are substituted into API calls. +/// Scoped at the Platform/Definition level (applies to ALL connections using this definition). +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(feature = "dummy", derive(fake::Dummy))] +#[serde(rename_all = "camelCase")] +pub struct ConnectionVariableMapping { + #[serde(rename = "_id")] + pub id: Id, + + /// The model definition this mapping applies to (Platform Level) + pub connection_model_definition_id: Id, + + /// List of variable-to-parameter bindings + pub bindings: Vec, + + /// Ownership information for multi-tenancy + pub ownership: Ownership, + + /// Environment (test/live) + pub environment: Environment, + + #[serde(flatten, default)] + pub record_metadata: RecordMetadata, +} + +/// A single binding that maps a connection variable to a target parameter +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(feature = "dummy", derive(fake::Dummy))] +#[serde(rename_all = "camelCase")] +pub struct VariableBinding { + /// Name of the variable in secrets (e.g., "hotel_id", "SALESFORCE_DOMAIN") + pub variable_name: String, + + /// Target parameter name in the API call (e.g., "id", "domain") + pub target_param: String, + + /// Where to inject the value + pub location: ParameterLocation, + + /// How to inject the value + #[serde(default)] + pub strategy: InjectionStrategy, + + /// Data type of the variable (for conversion) + #[serde(default)] + pub data_type: VariableDataType, +} + +/// Where to inject the variable value in the API request +#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[cfg_attr(feature = "dummy", derive(fake::Dummy))] +pub enum ParameterLocation { + /// URL path parameter: /hotels/{id} + PathParam, + /// Query string parameter: ?hotel_id=123 + QueryParam, + /// HTTP header: X-Hotel-Id: 123 + Header, + /// JSON body field: {"hotelId": "123"} + BodyField, +} + +/// Strategy for injecting the variable +#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[cfg_attr(feature = "dummy", derive(fake::Dummy))] +pub enum InjectionStrategy { + /// Always overwrite user input (Default, Secure) + Strict, + /// Only inject if parameter is missing (Flexible) + Fallback, + /// Append to existing value (for Lists) + Append, +} + +impl Default for InjectionStrategy { + fn default() -> Self { + Self::Strict + } +} + +/// Expected data type of the variable +#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[cfg_attr(feature = "dummy", derive(fake::Dummy))] +pub enum VariableDataType { + String, + Number, + Boolean, + /// Parse as JSON (Object or Array) + Json, +} + +impl Default for VariableDataType { + fn default() -> Self { + Self::String + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_serialize_connection_variable_mapping() { + let binding = VariableBinding { + variable_name: "hotel_id".to_string(), + target_param: "id".to_string(), + location: ParameterLocation::PathParam, + }; + + let json_val = serde_json::to_value(&binding).unwrap(); + assert_eq!(json_val["variableName"], "hotel_id"); + assert_eq!(json_val["targetParam"], "id"); + assert_eq!(json_val["location"], "PathParam"); + } + + #[test] + fn test_deserialize_parameter_location() { + let path_param: ParameterLocation = serde_json::from_value(json!("PathParam")).unwrap(); + assert_eq!(path_param, ParameterLocation::PathParam); + + let query_param: ParameterLocation = serde_json::from_value(json!("QueryParam")).unwrap(); + assert_eq!(query_param, ParameterLocation::QueryParam); + + let header: ParameterLocation = serde_json::from_value(json!("Header")).unwrap(); + assert_eq!(header, ParameterLocation::Header); + + let body_field: ParameterLocation = serde_json::from_value(json!("BodyField")).unwrap(); + assert_eq!(body_field, ParameterLocation::BodyField); + } +} diff --git a/osentities/src/domain/connection/mod.rs b/osentities/src/domain/connection/mod.rs index c16b9158..fc4bdc70 100644 --- a/osentities/src/domain/connection/mod.rs +++ b/osentities/src/domain/connection/mod.rs @@ -3,6 +3,7 @@ pub mod connection_definition; pub mod connection_model_definition; pub mod connection_model_schema; pub mod connection_oauth_definition; +pub mod connection_variable_mapping; use super::{ configuration::environment::Environment, diff --git a/osentities/src/domain/id/prefix.rs b/osentities/src/domain/id/prefix.rs index 90516809..0b203843 100644 --- a/osentities/src/domain/id/prefix.rs +++ b/osentities/src/domain/id/prefix.rs @@ -35,6 +35,7 @@ pub enum IdPrefix { UnitTest, EarlyAccess, Task, + ConnectionVariableMapping, } impl Display for IdPrefix { @@ -71,6 +72,7 @@ impl Display for IdPrefix { IdPrefix::UnitTest => write!(f, "ut"), IdPrefix::EarlyAccess => write!(f, "ea"), IdPrefix::Task => write!(f, "task"), + IdPrefix::ConnectionVariableMapping => write!(f, "conn_var_map"), } } } @@ -111,6 +113,7 @@ impl TryFrom<&str> for IdPrefix { "ut" => Ok(IdPrefix::UnitTest), "ea" => Ok(IdPrefix::EarlyAccess), "task" => Ok(IdPrefix::Task), + "conn_var_map" => Ok(IdPrefix::ConnectionVariableMapping), _ => Err(InternalError::invalid_argument( &format!("Invalid ID prefix: {}", s), None, @@ -153,6 +156,7 @@ impl From for String { IdPrefix::UnitTest => "ut".to_string(), IdPrefix::EarlyAccess => "ea".to_string(), IdPrefix::Task => "task".to_string(), + IdPrefix::ConnectionVariableMapping => "conn_var_map".to_string(), } } } diff --git a/osentities/src/domain/store/mod.rs b/osentities/src/domain/store/mod.rs index 9df17453..9613948c 100644 --- a/osentities/src/domain/store/mod.rs +++ b/osentities/src/domain/store/mod.rs @@ -100,5 +100,7 @@ generate_stores!( Transactions, "event-transactions", Clients, - "clients" + "clients", + ConnectionVariableMappings, + "connection-variable-mappings" ); diff --git a/unified/src/helper/mod.rs b/unified/src/helper/mod.rs index bc79938a..10dcebdd 100644 --- a/unified/src/helper/mod.rs +++ b/unified/src/helper/mod.rs @@ -19,7 +19,7 @@ pub fn match_route<'a>( .all(|(route_seg, path_seg)| { route_seg == path_seg || route_seg.starts_with(':') - || (route_seg.starts_with("{{") && route_seg.ends_with("}}")) + || (route_seg.starts_with('{') && route_seg.ends_with('}')) }) { return Some(route); @@ -43,7 +43,7 @@ pub fn template_route(model_definition_path: String, full_request_path: String) let mut template = String::new(); for (i, segment) in model_definition_segments.iter().enumerate() { - if segment.starts_with(':') || (segment.starts_with("{{") && segment.ends_with("}}")) { + if segment.starts_with(':') || (segment.starts_with('{') && segment.ends_with('}')) { template.push_str(full_request_segments[i]); } else { template.push_str(segment); diff --git a/unified/src/unified.rs b/unified/src/unified.rs index 6c2b5ad1..d06eeea1 100644 --- a/unified/src/unified.rs +++ b/unified/src/unified.rs @@ -26,6 +26,9 @@ use osentities::{ api_model_config::{ModelPaths, RequestModelPaths}, connection_model_definition::{ConnectionModelDefinition, CrudAction, PlatformInfo}, connection_model_schema::ConnectionModelSchema, + connection_variable_mapping::{ + ConnectionVariableMapping, InjectionStrategy, ParameterLocation, VariableDataType, + }, constant::*, database::DatabaseConfig, destination::{Action, Destination}, @@ -61,6 +64,7 @@ pub struct UnifiedDestination { pub connection_model_definitions_store: MongoStore, pub connection_model_schemas_cache: ConnectionModelSchemaCache, pub connection_model_schemas_store: MongoStore, + pub connection_variable_mappings_store: MongoStore, pub secrets_client: Arc, pub secrets_cache: SecretCache, pub http_client: reqwest::Client, @@ -109,6 +113,8 @@ impl UnifiedDestination { MongoStore::new(&db, &Store::ConnectionModelDefinitions).await?; let connection_model_schemas_store = MongoStore::new(&db, &Store::ConnectionModelSchemas).await?; + let connection_variable_mappings_store = + MongoStore::new(&db, &Store::ConnectionVariableMappings).await?; Ok(Self { connections_cache, @@ -117,6 +123,7 @@ impl UnifiedDestination { connection_model_definitions_store, connection_model_schemas_cache, connection_model_schemas_store, + connection_variable_mappings_store, secrets_client, secrets_cache, http_client, @@ -592,24 +599,169 @@ impl UnifiedDestination { }) .await?; + let secret_value = secret.as_value()?; + + + + let stored_mapping = self + .connection_variable_mappings_store + .get_many( + Some(doc! { + // Lookup by model definition only (Platform Level) + "connectionModelDefinitionId": config.id.to_string(), + }), + None, + None, + None, + None, + ) + .await? + .first() + .cloned(); + + let (mut headers, mut query_params, mut context) = (headers, query_params, context); + // We might need to modify the config (path), so we unwrap the Arc or clone + let mut config = config.as_ref().clone(); + + if let Some(mapping) = stored_mapping { + + for binding in mapping.bindings { + // Extract variable value from secret + // Extract variable value from secret + // Extract variable value from secret + let variable_value = if let Some(payload) = secret_value.get("OAUTH_REQUEST_PAYLOAD") { + payload + .get("formData") + .and_then(|fd| fd.get(&binding.variable_name)) + } else if let Some(fd) = secret_value.get("auth_form_data") { + fd.get(&binding.variable_name) + } else { + secret_value.get(&binding.variable_name) + }; + + if let Some(val) = variable_value { + let mut target_value_json = match binding.data_type { + VariableDataType::String => json!(val.as_str().map(|x| x.to_string()).unwrap_or_else(|| val.to_string())), + VariableDataType::Number => { + let s = val.as_str().map(|x| x.to_string()).unwrap_or_else(|| val.to_string()); + if let Ok(n) = s.parse::() { + json!(n) + } else if let Ok(n) = s.parse::() { + json!(n) + } else { + json!(s) + } + }, + VariableDataType::Boolean => { + let s = val.as_str().map(|x| x.to_string()).unwrap_or_else(|| val.to_string()); + json!(s.parse::().unwrap_or(false)) + }, + VariableDataType::Json => { + // Assuming the secret value is JSON-compatible or a raw object. + // If it's a string representation of JSON, parse it. + let s = val.as_str().unwrap_or(""); + serde_json::from_str(s).unwrap_or_else(|_| val.clone()) + } + }; + + match binding.location { + ParameterLocation::PathParam => { + let s = target_value_json.as_str().map(|x| x.to_string()).unwrap_or_else(|| target_value_json.to_string()); + let PlatformInfo::Api(ref mut api_config) = config.platform_info; + // Path Params usually always overwrite (Strict) due to resource identity. + api_config.path = api_config.path.replace(&format!("{{{}}}", binding.target_param), &s); + } + ParameterLocation::QueryParam => { + let s_val = target_value_json.as_str().map(|x| x.to_string()).unwrap_or_else(|| target_value_json.to_string()); + match binding.strategy { + InjectionStrategy::Strict => { query_params.insert(binding.target_param, s_val); }, + InjectionStrategy::Fallback => { query_params.entry(binding.target_param).or_insert(s_val); }, + InjectionStrategy::Append => { + query_params.entry(binding.target_param) + .and_modify(|existing| *existing = format!("{},{}", existing, s_val)) + .or_insert(s_val); + } + } + } + ParameterLocation::Header => { + if let Ok(header_name) = HeaderName::from_str(&binding.target_param) { + let s_val = target_value_json.as_str().map(|x| x.to_string()).unwrap_or_else(|| target_value_json.to_string()); + + if let Ok(header_value) = HeaderValue::from_str(&s_val) { + match binding.strategy { + InjectionStrategy::Strict => { headers.insert(header_name, header_value); }, + InjectionStrategy::Fallback => { if !headers.contains_key(&header_name) { headers.insert(header_name, header_value); } }, + InjectionStrategy::Append => { + // Headers support multiple values, but reqwest HeaderMap treats them as list? + // Or we append to string value? + // For robustness, comma-append string value. + if let Some(existing) = headers.get_mut(&header_name) { + if let Ok(existing_str) = existing.to_str() { + let new_val_str = format!("{},{}", existing_str, s_val); + if let Ok(new_header_val) = HeaderValue::from_str(&new_val_str) { + *existing = new_header_val; + } + } + } else { + headers.insert(header_name, header_value); + } + } + } + } + } + } + ParameterLocation::BodyField => { + if let Some(body_bytes) = &context { + if let Ok(mut json_body) = serde_json::from_slice::(body_bytes) { + if let Value::Object(ref mut map) = json_body { + match binding.strategy { + InjectionStrategy::Strict => { map.insert(binding.target_param, target_value_json); }, + InjectionStrategy::Fallback => { map.entry(binding.target_param).or_insert(target_value_json); }, + InjectionStrategy::Append => { + if let Some(existing) = map.get_mut(&binding.target_param) { + if let Value::Array(arr) = existing { + arr.push(target_value_json); + } else if let Value::String(s) = existing { + let s_val = target_value_json.as_str().unwrap_or("").to_string(); + let new_s = format!("{},{}", s, s_val); + *existing = json!(new_s); + } + } else { + map.insert(binding.target_param, target_value_json); + } + } + } + + if let Ok(new_bytes) = serde_json::to_vec(&json_body) { + context = Some(new_bytes); + } + } + } + } + } + } + } + } + } + // Template the route for passthrough actions let templated_config = match &destination.action { Action::Passthrough { path, .. } => { - let mut config_clone = (*config).clone(); + let mut config_clone = config.clone(); let PlatformInfo::Api(ref mut c) = config_clone.platform_info; let template = template_route(c.path.clone(), path.to_string()); c.path = template; config_clone.platform_info = PlatformInfo::Api(c.clone()); - Arc::new(config_clone) + config_clone } - _ => config.clone(), + _ => config, }; self.execute_model_definition( &templated_config, headers, &query_params, - &secret.as_value()?, + &secret_value, context, ) .await From 841c72dc0beb295c35d8884a1f9194b8afe45936 Mon Sep 17 00:00:00 2001 From: Aadil-Hasun Date: Thu, 22 Jan 2026 01:25:21 +0530 Subject: [PATCH 4/8] feat: Enrich knowledge records with connection variable mapping annotations based on injection strategies. --- api/src/logic/knowledge.rs | 127 +++++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 6 deletions(-) diff --git a/api/src/logic/knowledge.rs b/api/src/logic/knowledge.rs index f4a9d38c..ac84560a 100644 --- a/api/src/logic/knowledge.rs +++ b/api/src/logic/knowledge.rs @@ -1,14 +1,129 @@ -use super::{read_without_key, HookExt, PublicExt, RequestExt}; -use crate::server::{AppState, AppStores}; -use axum::{routing::get, Router}; +use super::{ReadResponse, HookExt, PublicExt, RequestExt}; +use crate::{ + helper::shape_mongo_filter, + router::ServerResponse, + server::{AppState, AppStores}, +}; +use axum::{ + extract::{Query, State}, + routing::get, + Router, Json, +}; use bson::doc; use fake::Dummy; -use osentities::{record_metadata::RecordMetadata, Id, MongoStore}; +use http::HeaderMap; +use osentities::{ + connection_variable_mapping::{ConnectionVariableMapping, InjectionStrategy}, + record_metadata::RecordMetadata, + Id, MongoStore, +}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use serde_json::Value; +use std::{collections::BTreeMap, sync::Arc}; +use tracing::error; pub fn get_router() -> Router> { - Router::new().route("/", get(read_without_key::)) + Router::new().route("/", get(read_knowledge)) +} + +/// Custom read handler that enriches knowledge with mapping annotations +async fn read_knowledge( + headers: HeaderMap, + query: Option>>, + State(state): State>, +) -> Result>>, osentities::PicaError> { + let query_params = shape_mongo_filter(query, None, Some(headers)); + + let store = state.app_stores.knowledge.clone(); + let mapping_store = state.app_stores.connection_variable_mapping.clone(); + + // Fetch knowledge records + let rows: Vec = store + .get_many( + Some(query_params.filter.clone()), + None, + None, + Some(query_params.limit), + Some(query_params.skip), + ) + .await?; + + let total = store.count(query_params.filter, None).await?; + + // Batch fetch ALL mappings for these definitions in a single query + let definition_ids: Vec = rows.iter().map(|r| r.id.to_string()).collect(); + let all_mappings: Vec = if !definition_ids.is_empty() { + mapping_store + .get_many( + Some(doc! { + "connectionModelDefinitionId": { "$in": &definition_ids }, + "deleted": false, + }), + None, + None, + None, // No limit - get all + None, + ) + .await + .unwrap_or_else(|e| { + error!("Error batch fetching mappings: {:?}", e); + Vec::new() + }) + } else { + Vec::new() + }; + + // Build HashMap for O(1) lookup + let mapping_map: std::collections::HashMap = all_mappings + .into_iter() + .map(|m| (m.connection_model_definition_id.to_string(), m)) + .collect(); + + // Enrich each record with mapping annotations + let mut enriched_rows: Vec = Vec::with_capacity(rows.len()); + for mut record in rows { + // O(1) lookup + if let Some(m) = mapping_map.get(&record.id.to_string()) { + let mut annotations = String::from("IMPORTANT: "); + let mut param_list: Vec = Vec::new(); + for binding in &m.bindings { + match binding.strategy { + InjectionStrategy::Strict => { + param_list.push(format!("'{}' (auto-filled, do NOT ask user)", binding.target_param)); + } + InjectionStrategy::Fallback => { + param_list.push(format!("'{}' (has default, only ask if user wants to override)", binding.target_param)); + } + InjectionStrategy::Append => { + param_list.push(format!("'{}' (partially pre-filled, user may add more)", binding.target_param)); + } + } + } + annotations.push_str(&format!( + "The following parameters are automatically handled by the system and do NOT need to be retrieved or asked for: {}.\n\n", + param_list.join(", ") + )); + // Prepend to existing knowledge + record.knowledge = Some( + record + .knowledge + .map(|k| format!("{}{}", annotations, k)) + .unwrap_or(annotations), + ); + } + + enriched_rows.push(serde_json::to_value(&record).unwrap_or_default()); + } + + Ok(Json(ServerResponse::new( + "read", + ReadResponse { + rows: enriched_rows, + skip: query_params.skip, + limit: query_params.limit, + total, + }, + ))) } struct ReadRequest; From 03d7119d4005fa9a768348ea4b7f88b6bdb1bed5 Mon Sep 17 00:00:00 2001 From: Aadil-Hasun Date: Thu, 22 Jan 2026 02:41:45 +0530 Subject: [PATCH 5/8] feat: add `connection_platform` to `ConnectionVariableMapping` and update its API routes and logic to support platform-level management without ownership filtering. --- api/src/logic/connection_variable_mapping.rs | 138 ++++++++++++++++-- api/src/router/secured_jwt.rs | 8 +- api/src/router/secured_key.rs | 5 - .../connection/connection_variable_mapping.rs | 4 + 4 files changed, 138 insertions(+), 17 deletions(-) diff --git a/api/src/logic/connection_variable_mapping.rs b/api/src/logic/connection_variable_mapping.rs index 01a1b7d6..df9a5853 100644 --- a/api/src/logic/connection_variable_mapping.rs +++ b/api/src/logic/connection_variable_mapping.rs @@ -1,17 +1,19 @@ -use super::{delete, read, update, HookExt, PublicExt, RequestExt}; +use super::{HookExt, PublicExt, ReadResponse, RequestExt, SuccessResponse}; use crate::{ + helper::shape_mongo_filter, router::ServerResponse, server::{AppState, AppStores}, }; use axum::{ - extract::{State, Json}, + extract::{Path, Query, State, Json}, http::StatusCode, response::IntoResponse, - routing::{patch, post}, + routing::{delete, get, patch, post}, Extension, Router, }; use bson::doc; use chrono::Utc; +use http::HeaderMap; use osentities::{ algebra::MongoStore, connection_variable_mapping::{ @@ -24,34 +26,145 @@ use osentities::{ ApplicationError, InternalError, PicaError, }; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use serde_json::Value; +use std::{collections::BTreeMap, sync::Arc}; +use tracing::error; pub fn get_router() -> Router> { Router::new() .route( "/", post(create_mapping) - .get(read::), + .get(read_mappings), // Custom handler without ownership filtering ) .route( "/:id", - patch(update::) - .delete(delete::), + patch(update_mapping) // Custom handler without ownership filtering + .delete(delete_mapping), // Custom handler without ownership filtering ) } +/// Custom read handler that returns ALL platform-level mappings without ownership filtering. +/// This is necessary because ConnectionVariableMappings are shared across all users of a platform. +async fn read_mappings( + headers: HeaderMap, + query: Option>>, + State(state): State>, +) -> Result>>, PicaError> { + // Pass None for event_access to bypass ownership filtering + let query_params = shape_mongo_filter(query, None, Some(headers)); + + let store = state.app_stores.connection_variable_mapping.clone(); + + let rows: Vec = store + .get_many( + Some(query_params.filter.clone()), + None, + None, + Some(query_params.limit), + Some(query_params.skip), + ) + .await?; + + let total = store.count(query_params.filter, None).await?; + + let res = ReadResponse { + rows: rows.into_iter().map(CreateRequest::public).collect(), + skip: query_params.skip, + limit: query_params.limit, + total, + }; + + Ok(Json(ServerResponse::new("read", res))) +} + +/// Custom update handler without ownership filtering. +/// Platform-level mappings can be updated by any authenticated user. +async fn update_mapping( + Path(id): Path, + State(state): State>, + Json(payload): Json, +) -> Result>, PicaError> { + let store = state.app_stores.connection_variable_mapping.clone(); + + // Platform-level: no ownership filter, just find by ID + let filter = doc! { + "_id": &id, + "deleted": false, + }; + + let Some(record) = store.get_one(filter).await? else { + return Err(ApplicationError::not_found( + &format!("Mapping with id {} not found", id), + None, + )); + }; + + let updated_record = payload.update(record); + + let bson = bson::to_bson_with_options(&updated_record, Default::default()).map_err(|e| { + error!("Could not serialize record into document: {e}"); + InternalError::serialize_error(e.to_string().as_str(), None) + })?; + + let document = doc! { "$set": bson }; + + store.update_one(&id, document).await?; + + Ok(Json(ServerResponse::new( + "update", + SuccessResponse { success: true }, + ))) +} + +/// Custom delete handler without ownership filtering. +/// Platform-level mappings can be deleted by any authenticated user (soft delete). +async fn delete_mapping( + Path(id): Path, + State(state): State>, +) -> Result>, PicaError> { + let store = state.app_stores.connection_variable_mapping.clone(); + + // Platform-level: no ownership filter, just find by ID + let filter = doc! { + "_id": &id, + "deleted": false, + }; + + let Some(record) = store.get_one(filter).await? else { + return Err(ApplicationError::not_found( + &format!("Mapping with id {} not found", id), + None, + )); + }; + + // Soft delete + store + .update_one( + &id, + doc! { + "$set": { + "deleted": true, + } + }, + ) + .await?; + + Ok(Json(ServerResponse::new("delete", CreateRequest::public(record)))) +} + async fn create_mapping( State(state): State>, Extension(access): Extension>, Json(payload): Json, ) -> Result { let stores = &state.app_stores; - // Check if mapping already exists for this definition - let mut filter = doc! { + // Check if mapping already exists for this definition (platform-level, no ownership filter) + // Mappings are shared across all users of a platform, so uniqueness is global + let filter = doc! { "connectionModelDefinitionId": payload.connection_model_definition_id.to_string(), "deleted": false, }; - filter.insert("ownership.buildableId", access.ownership.id.to_string()); let existing = stores .connection_variable_mapping @@ -98,6 +211,9 @@ pub struct CreateRequest { /// The model definition this mapping applies to (Platform Level) pub connection_model_definition_id: Id, + /// The platform this mapping belongs to (e.g., "blaze", "salesforce") + pub connection_platform: String, + /// List of variable-to-parameter bindings pub bindings: Vec, } @@ -135,6 +251,7 @@ impl RequestExt for CreateRequest { .id .unwrap_or_else(|| Id::now(IdPrefix::ConnectionVariableMapping)), connection_model_definition_id: self.connection_model_definition_id, + connection_platform: self.connection_platform.clone(), bindings: self .bindings .iter() @@ -157,6 +274,7 @@ impl RequestExt for CreateRequest { fn update(&self, mut record: Self::Output) -> Self::Output { record.connection_model_definition_id = self.connection_model_definition_id; + record.connection_platform = self.connection_platform.clone(); record.bindings = self .bindings .iter() diff --git a/api/src/router/secured_jwt.rs b/api/src/router/secured_jwt.rs index 253d6301..1bd7377f 100644 --- a/api/src/router/secured_jwt.rs +++ b/api/src/router/secured_jwt.rs @@ -2,8 +2,8 @@ use crate::{ logic::{ common_enum, common_model, connection_definition, connection_model_definition::{self}, - connection_model_schema, connection_oauth_definition, event_callback, openapi, platform, - platform_page, secrets, + connection_model_schema, connection_oauth_definition, connection_variable_mapping, + event_callback, openapi, platform, platform_page, secrets, }, middleware::jwt_auth::{self, JwtState}, server::AppState, @@ -40,6 +40,10 @@ pub async fn get_router(state: &Arc) -> Router> { .nest("/event-callbacks", event_callback::get_router()) .nest("/platform-pages", platform_page::get_router()) .nest("/platforms", platform::get_router()) + .nest( + "/connection-variable-mappings", + connection_variable_mapping::get_router(), + ) .route("/admin/connection/:id", get(secrets::get_admin_secret)) .route("/openapi", post(openapi::refresh_openapi)); diff --git a/api/src/router/secured_key.rs b/api/src/router/secured_key.rs index a16d7832..5062936a 100644 --- a/api/src/router/secured_key.rs +++ b/api/src/router/secured_key.rs @@ -5,7 +5,6 @@ use crate::{ connection_model_schema::{ public_get_connection_model_schema, PublicGetConnectionModelSchema, }, - connection_variable_mapping, event_access, events, knowledge, metrics, oauth, passthrough, secrets, tasks, unified, vault_connection, }, @@ -46,10 +45,6 @@ pub async fn get_router(state: &Arc) -> Router> { .nest("/secrets", secrets::get_router()) .nest("/unified", unified::get_router()) .nest("/vault/connections", vault_connection::get_router()) - .nest( - "/connection-variable-mappings", - connection_variable_mapping::get_router(), - ) .route( "/connection-model-definitions/test/:id", post(test_connection_model_definition), diff --git a/osentities/src/domain/connection/connection_variable_mapping.rs b/osentities/src/domain/connection/connection_variable_mapping.rs index bdc1fc9c..ddbec498 100644 --- a/osentities/src/domain/connection/connection_variable_mapping.rs +++ b/osentities/src/domain/connection/connection_variable_mapping.rs @@ -21,6 +21,10 @@ pub struct ConnectionVariableMapping { /// The model definition this mapping applies to (Platform Level) pub connection_model_definition_id: Id, + /// The platform this mapping belongs to (e.g., "blaze", "salesforce") + /// Used for filtering and grouping mappings + pub connection_platform: String, + /// List of variable-to-parameter bindings pub bindings: Vec, From 3ac3f9f72d2fa3bc5fa563426ff81120a44a3dfd Mon Sep 17 00:00:00 2001 From: Aadil-Hasun Date: Thu, 22 Jan 2026 14:34:06 +0530 Subject: [PATCH 6/8] fix(auth): implement dual-secret JWT verification for core and user tokens --- api/src/domain/config.rs | 4 ++ api/src/middleware/jwt_auth.rs | 72 +++++++++++++++++++++++++++++++--- scripts/generate_jwt_token.js | 4 +- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/api/src/domain/config.rs b/api/src/domain/config.rs index 2021df38..e100cef1 100644 --- a/api/src/domain/config.rs +++ b/api/src/domain/config.rs @@ -61,6 +61,10 @@ pub struct ConnectionsConfig { /// This is the admin secret for the API. Be sure this value is not the one use to generate /// tokens for the users as it gives access to sensitive admin endpoints. pub jwt_secret: String, + #[envconfig(from = "BUILDABLE_SECRET", default = "")] + /// The buildable secret used for core tokens (isBuildableCore: true). + /// Combined with JWT_SECRET to verify tokens from typescript-services. + pub buildable_secret: String, #[envconfig(from = "CONNECTIONS_URL", default = "http://localhost:3005")] /// Same as self url, but this may vary in a k8s environment hence it's a separate config pub connections_url: String, diff --git a/api/src/middleware/jwt_auth.rs b/api/src/middleware/jwt_auth.rs index 053253bd..8a8ccf85 100644 --- a/api/src/middleware/jwt_auth.rs +++ b/api/src/middleware/jwt_auth.rs @@ -6,13 +6,27 @@ use osentities::{ constant::{DEFAULT_AUDIENCE, DEFAULT_ISSUER, FALLBACK_AUDIENCE, FALLBACK_ISSUER}, ApplicationError, Claims, PicaError, BEARER_PREFIX, }; +use serde::Deserialize; use std::sync::Arc; -use tracing::info; +use tracing::{info, warn}; + +/// Minimal claims struct for peeking at isBuildableCore without full validation +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PartialClaims { + #[serde(default)] + is_buildable_core: bool, + #[serde(default)] + buildable_id: Option, +} #[derive(Clone)] pub struct JwtState { validation: Validation, - decoding_key: DecodingKey, + /// Decoding key for core tokens (BUILDABLE_SECRET + JWT_SECRET) + core_decoding_key: DecodingKey, + /// Base JWT secret for user tokens (JWT_SECRET + buildableId) + base_jwt_secret: String, } impl JwtState { @@ -20,9 +34,52 @@ impl JwtState { let mut validation = Validation::default(); validation.set_audience(&[DEFAULT_AUDIENCE, FALLBACK_AUDIENCE]); validation.set_issuer(&[DEFAULT_ISSUER, FALLBACK_ISSUER]); + + // Core secret: BUILDABLE_SECRET + JWT_SECRET (for isBuildableCore: true from internal services) + let core_secret = format!( + "{}{}", + state.config.buildable_secret, state.config.jwt_secret + ); + Self { validation, - decoding_key: DecodingKey::from_secret(state.config.jwt_secret.as_ref()), + core_decoding_key: DecodingKey::from_secret(core_secret.as_bytes()), + base_jwt_secret: state.config.jwt_secret.clone(), + } + } + + /// Get the appropriate decoding key based on token claims (direct matching, no fallback) + fn get_decoding_key(&self, token: &str) -> Result { + // Decode without verification to peek at claims + let mut peek_validation = Validation::default(); + peek_validation.insecure_disable_signature_validation(); + peek_validation.set_audience(&[DEFAULT_AUDIENCE, FALLBACK_AUDIENCE]); + peek_validation.set_issuer(&[DEFAULT_ISSUER, FALLBACK_ISSUER]); + + // Use a dummy key for peeking (signature validation is disabled) + let dummy_key = DecodingKey::from_secret(b"dummy"); + + let token_data = jsonwebtoken::decode::(token, &dummy_key, &peek_validation) + .map_err(|e| { + warn!("Failed to decode token claims: {:?}", e); + ApplicationError::unauthorized("Invalid token format", None) + })?; + + if token_data.claims.is_buildable_core { + // isBuildableCore: true → Core token (service-to-service) + // Uses: BUILDABLE_SECRET + JWT_SECRET + info!("Token type: core (isBuildableCore: true)"); + Ok(self.core_decoding_key.clone()) + } else { + // isBuildableCore: false → User token + // Uses: JWT_SECRET + buildableId + let buildable_id = token_data.claims.buildable_id.ok_or_else(|| { + warn!("User token missing buildableId"); + ApplicationError::unauthorized("Invalid token: missing buildableId", None) + })?; + info!("Token type: user (buildableId: {})", buildable_id); + let secret = format!("{}{}", self.base_jwt_secret, buildable_id); + Ok(DecodingKey::from_secret(secret.as_bytes())) } } } @@ -58,13 +115,18 @@ pub async fn jwt_auth_middleware( let token = &auth_header[BEARER_PREFIX.len()..]; - match jsonwebtoken::decode::(token, &state.decoding_key, &state.validation) { + // Get the appropriate decoding key based on token type (direct matching) + let decoding_key = state.get_decoding_key(token)?; + + // Validate the token with the selected key + match jsonwebtoken::decode::(token, &decoding_key, &state.validation) { Ok(decoded_token) => { + info!("JWT token validated successfully"); req.extensions_mut().insert(Arc::new(decoded_token.claims)); Ok(next.run(req).await) } Err(e) => { - info!("invalid JWT token : {:?}", e); + warn!("JWT validation failed: {:?}", e); Err(ApplicationError::forbidden( "You are not authorized to access this resource", None, diff --git a/scripts/generate_jwt_token.js b/scripts/generate_jwt_token.js index 8f358eca..fe5f8ec1 100644 --- a/scripts/generate_jwt_token.js +++ b/scripts/generate_jwt_token.js @@ -31,7 +31,7 @@ const payload = { iss: "pica" }; -// const compositeSecret = JWT_SECRET + payload.buildableId; -const token = jwt.sign(payload, JWT_SECRET); +const compositeSecret = JWT_SECRET + payload.buildableId; // const token = jwt.sign(payload, JWT_SECRET); +const token = jwt.sign(payload, compositeSecret); console.log(token); \ No newline at end of file From 9a5b70ee4eb3dc24eb07b271a4d90c586202a4fb Mon Sep 17 00:00:00 2001 From: Aadil-Hasun Date: Mon, 26 Jan 2026 13:05:22 +0530 Subject: [PATCH 7/8] feat: Add batch update functionality for connection model definitions with a new `PATCH` endpoint and corresponding test. --- api/src/logic/connection_model_definition.rs | 201 ++++++++++++++++++- api/tests/http/crud.rs | 100 +++++++++ 2 files changed, 299 insertions(+), 2 deletions(-) diff --git a/api/src/logic/connection_model_definition.rs b/api/src/logic/connection_model_definition.rs index 3b19bb30..d658c44a 100644 --- a/api/src/logic/connection_model_definition.rs +++ b/api/src/logic/connection_model_definition.rs @@ -1,5 +1,5 @@ use super::{ - create, delete, read, update, HookExt, PublicExt, ReadResponse, RequestExt, + create, delete, read, update, HookExt, PublicExt, ReadResponse, RequestExt, SuccessResponse, }; use crate::{ helper::shape_mongo_filter, @@ -48,7 +48,8 @@ pub fn get_router() -> Router> { .route( "/", post(create::) - .get(read::), + .get(read::) + .patch(update_many), ) .route( "/:id", @@ -57,6 +58,202 @@ pub fn get_router() -> Router> { ) } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchUpdateResult { + pub id: Option, + pub success: bool, + pub error: Option, +} + + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PartialUpdateRequest { + #[serde(rename = "_id")] + pub id: Option, + pub connection_platform: Option, + pub connection_definition_id: Option, + pub platform_version: Option, + pub title: Option, + pub name: Option, + pub model_name: Option, + pub base_url: Option, + pub path: Option, + pub auth_method: Option, + pub action_name: Option, + #[serde(with = "http_serde_ext_ios::method::option", rename = "action", default)] + pub http_method: Option, + #[serde( + with = "http_serde_ext_ios::header_map::option", + skip_serializing_if = "Option::is_none", + default + )] + pub headers: Option, + pub query_params: Option>, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub extractor_config: Option, + pub schemas: Option, + pub samples: Option, + pub responses: Option>, + pub version: Option, + pub is_default_crud_mapping: Option, + pub test_connection_payload: Option, + pub test_connection_status: Option, + pub mapping: Option, + pub paths: Option, + pub supported: Option, + pub active: Option, + pub knowledge: Option, + pub tags: Option>, +} + +pub async fn update_many( + access: Option>>, + State(state): State>, + Json(payload): Json>, +) -> Result>>, PicaError> { + let mut results = Vec::new(); + + for request in payload { + let id_str = match &request.id { + Some(id) => id.to_string(), + None => { + results.push(BatchUpdateResult { + id: None, + success: false, + error: Some("Missing ID".to_string()), + }); + continue; + } + }; + + let mut query = shape_mongo_filter( + None, + access.clone().map(|e| { + let Extension(e) = e; + e + }), + None, + ); + query.filter.insert("_id", &id_str); + + let store = CreateRequest::get_store(state.app_stores.clone()); + + match store.get_one(query.filter).await { + Ok(Some(mut record)) => { + // Merging Logic + if let Some(val) = request.connection_platform { record.connection_platform = val; } + if let Some(val) = request.connection_definition_id { record.connection_definition_id = val; } + if let Some(val) = request.platform_version { record.platform_version = val; } + if let Some(val) = request.title { record.title = val; } + if let Some(val) = request.name { record.name = val; } + if let Some(val) = request.model_name { record.model_name = val; } + if let Some(val) = request.action_name { record.action_name = val; } + if let Some(val) = request.http_method { record.action = val; } + + // ApiModelConfig Merge + if let PlatformInfo::Api(ref mut api_config) = record.platform_info { + if let Some(val) = request.base_url { api_config.base_url = val; } + if let Some(val) = request.path { api_config.path = val; } + if let Some(val) = request.auth_method { api_config.auth_method = val; } + if let Some(val) = request.headers { api_config.headers = Some(val); } + if let Some(val) = request.query_params { api_config.query_params = Some(val); } + if let Some(val) = request.schemas { api_config.schemas = val; } + if let Some(val) = request.samples { api_config.samples = val; } + if let Some(val) = request.responses { api_config.responses = val; } + if let Some(val) = request.paths { api_config.paths = Some(val); } + } + + if let Some(val) = request.extractor_config { record.extractor_config = Some(val); } + if let Some(val) = request.version { record.record_metadata.version = val; } + if let Some(val) = request.is_default_crud_mapping { record.is_default_crud_mapping = Some(val); } + if let Some(val) = request.test_connection_payload { record.test_connection_payload = Some(val); } + if let Some(val) = request.test_connection_status { record.test_connection_status = val; } + if let Some(val) = request.mapping { record.mapping = Some(val); } + if let Some(val) = request.supported { record.supported = val; } + if let Some(val) = request.active { record.record_metadata.active = val; } + if let Some(val) = request.knowledge { record.knowledge = Some(val); } + if let Some(val) = request.tags { record.record_metadata.tags = val; } + + // Regenerate Key (Same logic as RequestExt) + // Note: If fields involved in key generation didn't change, this stays same, + // but we regenerate to be safe if any one of them changed. + // Key generation relies on: connection_platform, platform_version, model_name, action_name, path, name + // We need 'path' which is inside api_config. + let path_val = if let PlatformInfo::Api(ref api_config) = record.platform_info { + api_config.path.clone() + } else { + String::new() + }; + + let key = format!( + "api::{}::{}::{}::{}::{}::{}", + record.connection_platform, + record.platform_version, + record.model_name, + record.action_name, + path_val, + record.name + ).to_lowercase(); + record.key = key; + + let bson_result = bson::to_bson_with_options(&record, Default::default()); + + match bson_result { + Ok(bson) => { + let document = doc! { "$set": bson }; + match store.update_one(&id_str, document).await { + Ok(_) => { + CreateRequest::after_update_hook(&record, &state.app_stores) + .await + .ok(); + results.push(BatchUpdateResult { + id: Some(id_str), + success: true, + error: None, + }); + } + Err(e) => { + error!("Error updating in store: {e}"); + results.push(BatchUpdateResult { + id: Some(id_str), + success: false, + error: Some(e.to_string()), + }); + } + } + } + Err(e) => { + error!("Could not serialize record into document: {e}"); + results.push(BatchUpdateResult { + id: Some(id_str), + success: false, + error: Some(e.to_string()), + }); + } + } + } + Ok(None) => { + results.push(BatchUpdateResult { + id: Some(id_str), + success: false, + error: Some("Record not found".to_string()), + }); + } + Err(e) => { + error!("Error getting record in store: {e}"); + results.push(BatchUpdateResult { + id: Some(id_str), + success: false, + error: Some(e.to_string()), + }); + } + } + } + + Ok(Json(ServerResponse::new("batch_update", results))) +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TestConnectionPayload { diff --git a/api/tests/http/crud.rs b/api/tests/http/crud.rs index 6ccb99fa..a4326d2e 100644 --- a/api/tests/http/crud.rs +++ b/api/tests/http/crud.rs @@ -327,3 +327,103 @@ async fn test_common_model_crud() { assert!(get_models.rows.is_empty()); } + +#[tokio::test] +async fn test_connection_model_definitions_batch_update() { + let server = TestServer::new(None).await; + + // 1. Create two Connection Model Definitions + let payload1: connection_model_definition::CreateRequest = Faker.fake(); + let payload1_json = serde_json::to_value(&payload1).unwrap(); + let res1 = server + .send_request::( + "v1/connection-model-definitions", + Method::POST, + Some(&server.live_key), + Some(&payload1_json), + ) + .await + .unwrap(); + assert_eq!(res1.code, StatusCode::OK); + let model1: ConnectionModelDefinition = serde_json::from_value(res1.data).expect("Failed to deserialize model 1"); + + let payload2: connection_model_definition::CreateRequest = Faker.fake(); + let payload2_json = serde_json::to_value(&payload2).unwrap(); + let res2 = server + .send_request::( + "v1/connection-model-definitions", + Method::POST, + Some(&server.live_key), + Some(&payload2_json), + ) + .await + .unwrap(); + assert_eq!(res2.code, StatusCode::OK); + let model2: ConnectionModelDefinition = serde_json::from_value(res2.data).expect("Failed to deserialize model 2"); + + // 2. Prepare Batch Update Payload + // 2. Prepare Batch Update Payload - using partial structure via json! + // We only update connection_platform, other fields (like name/title) should remain untouched. + + let update_payload1 = json!({ + "_id": model1.id, + "connectionPlatform": "UpdatedPlatform1" + }); + + let update_payload2 = json!({ + "_id": model2.id, + "connectionPlatform": "UpdatedPlatform2" + }); + + let batch_json = json!([update_payload1, update_payload2]); + + // 3. Send Batch Update Request + let res = server + .send_request::( + "v1/connection-model-definitions", + Method::PATCH, + Some(&server.live_key), + Some(&batch_json), + ) + .await + .unwrap(); + + assert_eq!(res.code, StatusCode::OK); + + // 4. Verify Response + let results: Vec = serde_json::from_value(res.data).expect("Failed to deserialize batch results"); + assert_eq!(results.len(), 2); + + let result1 = results.iter().find(|r| r.id.as_ref() == Some(&model1.id.to_string())).expect("Result for model 1 not found"); + assert!(result1.success); + + let result2 = results.iter().find(|r| r.id.as_ref() == Some(&model2.id.to_string())).expect("Result for model 2 not found"); + assert!(result2.success); + + // 5. Verify Database State + let res_get1 = server + .send_request::( + &format!("v1/connection-model-definitions?_id={}", model1.id), + Method::GET, + Some(&server.live_key), + None, + ) + .await + .unwrap(); + let response1: ReadResponse = serde_json::from_value(res_get1.data).unwrap(); + let updated_model1 = response1.rows.first().expect("Model 1 not found"); + assert_eq!(updated_model1.connection_platform, "UpdatedPlatform1"); + + let res_get2 = server + .send_request::( + &format!("v1/connection-model-definitions?_id={}", model2.id), + Method::GET, + Some(&server.live_key), + None, + ) + .await + .unwrap(); + let response2: ReadResponse = serde_json::from_value(res_get2.data).unwrap(); + let updated_model2 = response2.rows.first().expect("Model 2 not found"); + assert_eq!(updated_model2.connection_platform, "UpdatedPlatform2"); +} From b33f019b8a716184cf521a8dd6f983577d274b1c Mon Sep 17 00:00:00 2001 From: shiffa-04 Date: Tue, 27 Jan 2026 12:42:35 +0530 Subject: [PATCH 8/8] feat(auth): enhance CreateRequest to include authentication fields and connection form data in the connection definition --- .gitignore | 1 + api/src/logic/connection_definition.rs | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.gitignore b/.gitignore index ee9680a1..8ca740b8 100644 --- a/.gitignore +++ b/.gitignore @@ -215,6 +215,7 @@ mongo-data/ #Deployment files deployment.yaml +k8s/ # Scripts *.py \ No newline at end of file diff --git a/api/src/logic/connection_definition.rs b/api/src/logic/connection_definition.rs index e3f14def..3c12d93a 100644 --- a/api/src/logic/connection_definition.rs +++ b/api/src/logic/connection_definition.rs @@ -482,6 +482,31 @@ impl RequestExt for CreateRequest { record.platform.clone_from(&self.platform); record.multi_env = self.multi_env; record.record_metadata.active = self.active; + + // Update authentication fields + let auth_secrets: Vec = self + .authentication + .iter() + .map(|item| AuthSecret { + name: item.name.to_string(), + }) + .collect(); + + let connection_form_items: Vec = self + .authentication + .iter() + .map(|item| FormDataItem { + name: item.name.clone(), + r#type: item.r#type.clone(), + label: item.label.clone(), + placeholder: item.placeholder.clone(), + }) + .collect(); + + record.auth_secrets = auth_secrets; + record.auth_method.clone_from(&self.auth_method); + record.frontend.connection_form.form_data = connection_form_items; + record }