# Replacing User ID with Consent User ID at Connector Level ## Overview This document explains where and how to replace the authenticated user's `user_id` with a `user_id` from a consent at the connector level in the OBP-API. This replacement should occur after security guards (authentication/authorization) but just before database operations or external messaging (RabbitMQ, REST, etc.). ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [Key Concepts](#key-concepts) 3. [Where to Make Changes](#where-to-make-changes) 4. [Implementation Guide](#implementation-guide) 5. [Important Considerations](#important-considerations) --- ## Architecture Overview ### Call Flow ``` API Endpoint ↓ Authentication/Authorization (Security Guards) ↓ CallContext (contains user + consenter) ↓ ┌─────────────────────────────────────────────┐ │ THIS IS WHERE USER_ID REPLACEMENT HAPPENS │ └─────────────────────────────────────────────┘ ↓ ├──→ External Connectors (RabbitMQ, REST, Akka, etc.) │ ↓ │ toOutboundAdapterCallContext() │ ↓ │ External Adapter/System │ └──→ LocalMappedConnector (Built-in Database) ↓ Direct Database Operations ``` ### CallContext Structure The `CallContext` class (in `code.api.util.ApiSession`) contains: ```scala case class CallContext( user: Box[User] = Empty, // The authenticated user consenter: Box[User] = Empty, // The user from consent (if present) consumer: Box[Consumer] = Empty, // ... other fields ) ``` When Berlin Group consents are applied, the `consenter` field is populated with the consent user: ```scala // From ConsentUtil.scala line 596 val updatedCallContext = callContext.copy(consenter = user) ``` --- ## Key Concepts ### 1. Consent User vs Authenticated User - **Authenticated User**: The user/application that made the API request (in `callContext.user`) - **Consent User**: The account holder who gave consent for access (in `callContext.consenter`) In consent-based scenarios (e.g., Berlin Group PSD2), a TPP (Third Party Provider) authenticates, but operates on behalf of the PSU (Payment Service User) who gave consent. ### 2. Connector Types #### External Connectors - **RabbitMQConnector_vOct2024** - **RestConnector_vMar2019** - **AkkaConnector_vDec2018** - **StoredProcedureConnector_vDec2019** - **EthereumConnector_vSept2025** - **CardanoConnector_vJun2025** These send messages to external adapters/systems and use `OutboundAdapterCallContext`. #### Internal Connector - **LocalMappedConnector** Works directly with the OBP database (Mapper/ORM layer) and does NOT use `OutboundAdapterCallContext`. --- ## Where to Make Changes ### For External Connectors **Location**: `OBP-API/obp-api/src/main/scala/code/api/util/ApiSession.scala` **Method**: `CallContext.toOutboundAdapterCallContext` (lines 65-115) This is the **single transformation point** where `CallContext` is converted to `OutboundAdapterCallContext` before being sent to external systems. Example of how it's used in connectors: ```scala // From RabbitMQConnector_vOct2024.scala line 2204 override def makePaymentv210(..., callContext: Option[CallContext]): ... = { val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, ...) val response: Future[Box[InBound]] = sendRequest[InBound]("obp_make_paymentv210", req, callContext) } ``` ### For LocalMappedConnector **Challenge**: LocalMappedConnector does NOT use `toOutboundAdapterCallContext`. It works directly with database entities. **Key Location**: `OBP-API/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala` Methods like `savePayment` (line 2223) and `getBankAccountsForUserLegacy` (line 624) work directly with parameters and database objects. --- ## Implementation Guide ### Option 1: Modify toOutboundAdapterCallContext (External Connectors Only) This approach works for RabbitMQ, REST, Akka, and other external connectors. #### Current Implementation ```scala // ApiSession.scala lines 65-115 def toOutboundAdapterCallContext: OutboundAdapterCallContext = { for { user <- this.user username <- tryo(Some(user.name)) currentResourceUserId <- tryo(Some(user.userId)) // <-- Uses authenticated user consumerId = this.consumer.map(_.consumerId.get).openOr("") permission <- Views.views.vend.getPermissionForUser(user) views <- tryo(permission.views) linkedCustomers <- tryo(CustomerX.customerProvider.vend.getCustomersByUserId(user.userId)) // ... OutboundAdapterCallContext( correlationId = this.correlationId, sessionId = this.sessionId, consumerId = Some(consumerId), generalContext = Some(generalContextFromPassThroughHeaders), outboundAdapterAuthInfo = Some(OutboundAdapterAuthInfo( userId = currentResourceUserId, // <-- Authenticated user's ID sent to adapter username = username, linkedCustomers = likedCustomersBasic, userAuthContext = basicUserAuthContexts, if (authViews.isEmpty) None else Some(authViews))), outboundAdapterConsenterInfo = if (this.consenter.isDefined){ Some(OutboundAdapterAuthInfo( username = this.consenter.toOption.map(_.name))) } else { None } ) } } ``` #### Proposed Change ```scala def toOutboundAdapterCallContext: OutboundAdapterCallContext = { for { user <- this.user // Determine the effective user: use consenter if present, otherwise authenticated user val effectiveUser = this.consenter.toOption.getOrElse(user) username <- tryo(Some(effectiveUser.name)) currentResourceUserId <- tryo(Some(effectiveUser.userId)) // <-- NOW uses consent user if present consumerId = this.consumer.map(_.consumerId.get).openOr("") // Use effectiveUser for permissions and linked data permission <- Views.views.vend.getPermissionForUser(effectiveUser) views <- tryo(permission.views) linkedCustomers <- tryo(CustomerX.customerProvider.vend.getCustomersByUserId(effectiveUser.userId)) likedCustomersBasic = if (linkedCustomers.isEmpty) None else Some(createInternalLinkedBasicCustomersJson(linkedCustomers)) userAuthContexts <- UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(effectiveUser.userId) basicUserAuthContextsFromDatabase = if (userAuthContexts.isEmpty) None else Some(createBasicUserAuthContextJson(userAuthContexts)) generalContextFromPassThroughHeaders = createBasicUserAuthContextJsonFromCallContext(this) basicUserAuthContexts = Some(basicUserAuthContextsFromDatabase.getOrElse(List.empty[BasicUserAuthContext])) authViews <- tryo( for { view <- views (account, callContext) <- code.bankconnectors.LocalMappedConnector.getBankAccountLegacy(view.bankId, view.accountId, Some(this)) ?~! {BankAccountNotFound} internalCustomers = createAuthInfoCustomersJson(account.customerOwners.toList) internalUsers = createAuthInfoUsersJson(account.userOwners.toList) viewBasic = ViewBasic(view.viewId.value, view.name, view.description) accountBasic = AccountBasic( account.accountId.value, account.accountRoutings, internalCustomers.customers, internalUsers.users) } yield AuthView(viewBasic, accountBasic) ) } yield { OutboundAdapterCallContext( correlationId = this.correlationId, sessionId = this.sessionId, consumerId = Some(consumerId), generalContext = Some(generalContextFromPassThroughHeaders), outboundAdapterAuthInfo = Some(OutboundAdapterAuthInfo( userId = currentResourceUserId, // <-- Now contains consent user's ID username = username, // <-- Now contains consent user's name linkedCustomers = likedCustomersBasic, userAuthContext = basicUserAuthContexts, if (authViews.isEmpty) None else Some(authViews))), outboundAdapterConsenterInfo = if (this.consenter.isDefined) { Some(OutboundAdapterAuthInfo( userId = Some(this.consenter.toOption.get.userId), // <-- ADD this username = this.consenter.toOption.map(_.name))) } else { None } ) }}.openOr(OutboundAdapterCallContext( this.correlationId, this.sessionId)) } ``` ### Option 2: Add Helper Method (For Both Internal and External) Add a convenience method to `CallContext` that returns the effective user: ```scala // Add to CallContext class in ApiSession.scala case class CallContext( // ... existing fields ) { // ... existing methods /** * Returns the consent user if present, otherwise returns the authenticated user. * Use this method when you need the "effective" user for operations. */ def effectiveUser: Box[User] = consenter.or(user) /** * Returns the user ID of the effective user (consent user if present, otherwise authenticated user). * Throws exception if no user is available. */ def effectiveUserId: String = effectiveUser.map(_.userId).openOrThrowException(UserNotLoggedIn) // ... rest of class } ``` Then use throughout the codebase: ```scala // In LocalMappedConnector or other places override def getBankAccountsForUserLegacy(..., callContext: Option[CallContext]): ... = { // Instead of getting user from parameters val userId = callContext.map(_.effectiveUserId).getOrElse(...) val userAuthContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(userId) // ... } ``` ### Option 3: Hybrid Approach (Recommended) Combine both options: 1. **For External Connectors**: Implement the change in `toOutboundAdapterCallContext` (Option 1) 2. **For Internal Code**: Add helper methods (Option 2) and use them where appropriate 3. **At API Layer**: Consider handling critical consent-based operations at the endpoint level before calling connectors --- ## Important Considerations ### 1. LocalMappedConnector Limitations The `LocalMappedConnector` typically doesn't use `CallContext` for user information in its core transaction methods. For example: ```scala // LocalMappedConnectorInternal.scala line 613 def saveTransaction( fromAccount: BankAccount, toAccount: BankAccount, // ... other parameters // NOTE: No callContext parameter! ): Box[TransactionId] = { // Creates transaction directly from account objects mappedTransaction <- tryo(MappedTransaction.create .bank(fromAccount.bankId.value) .account(fromAccount.accountId.value) // ... } ``` The user information is embedded in the `BankAccount` objects passed to these methods, not extracted from `CallContext`. ### 2. Consent Flow The consent user is set in `ConsentUtil.scala`: ```scala // ConsentUtil.scala line 596 case Full(storedConsent) => val user = Users.users.vend.getUserByUserId(storedConsent.userId) val updatedCallContext = callContext.copy(consenter = user) ``` This happens during Berlin Group consent validation, so `callContext.consenter` will only be populated for consent-based requests. ### 3. OutboundAdapterAuthInfo Structure The structure sent to external adapters: ```scala // From CommonModel.scala line 1221 case class OutboundAdapterAuthInfo( userId: Option[String] = None, // Main user ID username: Option[String] = None, // Main username linkedCustomers: Option[List[BasicLinkedCustomer]] = None, userAuthContext: Option[List[BasicUserAuthContext]] = None, authViews: Option[List[AuthView]] = None, ) // And in OutboundAdapterCallContext line 1207 case class OutboundAdapterCallContext( correlationId: String = "", sessionId: Option[String] = None, consumerId: Option[String] = None, generalContext: Option[List[BasicGeneralContext]] = None, outboundAdapterAuthInfo: Option[OutboundAdapterAuthInfo] = None, // Main user outboundAdapterConsenterInfo: Option[OutboundAdapterAuthInfo] = None, // Consent user ) ``` Currently, `outboundAdapterAuthInfo` contains the authenticated user, and `outboundAdapterConsenterInfo` contains minimal consent user info. After the proposed change, `outboundAdapterAuthInfo` would contain the consent user when present. ### 4. Security Implications **IMPORTANT**: This change means that operations will be performed using the consent user's identity rather than the authenticated user's identity. Ensure: - Security guards have already validated that the authenticated user has permission to act on behalf of the consent user - Audit logs capture both the authenticated user (who made the request) and the effective user (whose account is being accessed) - The consent is valid and not expired before this transformation happens ### 5. Backward Compatibility When implementing this change: - Existing requests without consent should continue to work (use authenticated user) - External adapters might need updates if they rely on specific user ID mappings - Consider adding a feature flag to enable/disable this behavior during testing ### 6. Testing Strategy Test scenarios: 1. **No Consent**: Request with authenticated user only → should use authenticated user's ID 2. **With Valid Consent**: Request with consent → should use consent user's ID 3. **Expired Consent**: Should fail before reaching connector level 4. **Mixed Operations**: Some endpoints with consent, some without → each should use correct user 5. **External vs Internal**: Verify both external connectors and LocalMappedConnector behave correctly ### 7. Dynamic Entities **Challenge**: Dynamic Entities pass `userId` explicitly as a parameter rather than relying solely on `CallContext`. **Location**: `OBP-API/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala` #### Current Implementation In the Dynamic Entity generic endpoint (line 132): ```scala (box, _) <- NewStyle.function.invokeDynamicConnector( operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, Some(u.userId), // <-- Uses authenticated user's ID directly isPersonalEntity, Some(cc) ) ``` The `userId` parameter is extracted from the authenticated user (`u.userId`) and passed directly to the connector. #### Flow Through to Connector This userId flows through: 1. **NewStyle.function.invokeDynamicConnector** (ApiUtil.scala line 3372) 2. **Connector.dynamicEntityProcess** (Connector.scala line 1766) 3. **LocalMappedConnector.dynamicEntityProcess** (LocalMappedConnector.scala line 4324) 4. **DynamicDataProvider methods** (DynamicDataProvider.scala line 35-43) Example in LocalMappedConnector: ```scala case GET_ALL => Full { val dataList = DynamicDataProvider.connectorMethodProvider.vend .getAllDataJson(bankId, entityName, userId, isPersonalEntity) // <-- userId used here JArray(dataList) } ``` #### Proposed Solution **Option A: Use effectiveUserId in API Layer** Modify `APIMethodsDynamicEntity.scala` to use the consent user when present: ```scala for { (Full(u), callContext) <- authenticatedAccess(callContext) // Determine effective user: consent user if present, otherwise authenticated user effectiveUserId = callContext.consenter.map(_.userId).openOr(u.userId) // ... other validations ... (box, _) <- NewStyle.function.invokeDynamicConnector( operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, Some(effectiveUserId), // <-- Now uses consent user if available isPersonalEntity, Some(cc) ) } yield { // ... } ``` **Option B: Add Helper to CallContext (Recommended)** Use the `effectiveUserId` helper method proposed in Option 2: ```scala for { (Full(u), callContext) <- authenticatedAccess(callContext) // ... other validations ... (box, _) <- NewStyle.function.invokeDynamicConnector( operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, Some(callContext.effectiveUserId), // <-- Uses helper method isPersonalEntity, Some(cc) ) } yield { // ... } ``` #### Impact Analysis Dynamic Entities use `userId` for: 1. **Personal Entities (`isPersonalEntity = true`)**: Scoping data to specific users - GET_ALL: Filters data by userId - GET_ONE: Validates ownership by userId - CREATE: Associates new data with userId - UPDATE/DELETE: Validates user owns the data 2. **System/Bank Level Entities (`isPersonalEntity = false`)**: userId may be optional or used for audit Example from `MappedDynamicDataProvider.scala`: ```scala override def get(bankId: Option[String], entityName: String, id: String, userId: Option[String], isPersonalEntity: Boolean): Box[DynamicDataT] = { if (bankId.isEmpty && isPersonalEntity) { DynamicData.find( By(DynamicData.DynamicEntityName, entityName), By(DynamicData.DynamicDataId, id), By(DynamicData.UserId, userId.get) // <-- Filters by userId for personal entities ) match { case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id, userId = $userId") } } // ... } ``` #### Recommendation For Dynamic Entities with consent support: 1. **Implement Option B**: Use `callContext.effectiveUserId` helper 2. **Update all invocation points** in `APIMethodsDynamicEntity.scala` (lines 132, 213, 273, 280, 343, 351) 3. **Document behavior**: Personal Dynamic Entities will be scoped to the consent user when consent is present 4. **Security consideration**: Ensure consent validation happens before reaching Dynamic Entity operations #### Files to Modify - `OBP-API/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala` - Replace `Some(u.userId)` with `Some(callContext.effectiveUserId)` - `OBP-API/obp-api/src/main/scala/code/api/util/ApiSession.scala` - Add `effectiveUserId` helper to CallContext --- ## Related Files ### Key Files to Modify - `OBP-API/obp-api/src/main/scala/code/api/util/ApiSession.scala` - CallContext and toOutboundAdapterCallContext - `OBP-API/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala` - Internal database operations ### Files to Review - `OBP-API/obp-api/src/main/scala/code/api/util/ConsentUtil.scala` - Consent application logic - `OBP-API/obp-api/src/main/scala/code/bankconnectors/Connector.scala` - Base connector trait - `OBP-API/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala` - RabbitMQ example - `OBP-API/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala` - REST example - `OBP-API/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala` - DTO structures --- ## Summary To replace user_id at the connector level with consent user_id: 1. **Primary Solution (External Connectors)**: Modify `toOutboundAdapterCallContext` method in `ApiSession.scala` to use `consenter` when present instead of `user` 2. **Secondary Solution (LocalMappedConnector)**: Add helper methods like `effectiveUser` and `effectiveUserId` to `CallContext` and use them where user information is needed 3. **Best Practice**: Handle at API endpoint level where possible, before calling connector methods 4. **Key Insight**: The architectural difference between external connectors (which have a clear transformation boundary) and LocalMappedConnector (which works directly with domain objects) means there's no single universal solution The `toOutboundAdapterCallContext` method is the ideal place for external connectors because it occurs: - ✅ After security guards (authentication/authorization complete) - ✅ Before external communication (database/RabbitMQ/REST) - ✅ At a single transformation point (DRY principle) - ✅ With access to both authenticated user and consent user --- _Last Updated: 2024_ _OBP-API Version: v5.1.0+_