OBP-API/ideas/REPLACE_USER_ID_WITH_CONSENT_USER_ID.md

561 lines
20 KiB
Markdown
Raw Permalink Normal View History

# 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+_