On Behalf Of
When a User grants a here to an application, such as an AI agent, the application calls OBP under an identity of its own: a consent user, which OBP creates for that Consent. A call made that way has two Users behind it. user_id is the caller, the consent user that authenticated. on_behalf_of_user_id is the User the call was made for, the one who granted the Consent. When a User calls OBP for themselves, the two are the same User.
OBP finds the User acted for itself, from the Consent the caller authenticated with. It never takes it from a request body, header or query parameter, so a caller cannot name somebody else to act for.
This matters whenever OBP stores a user id. A consent user belongs to its Consent, so a row stored against it, such as an account holder, belongs to an identity that ends with the Consent, and the User who asked for it can no longer see it. So every database column that stores a user id has a policy, which says whose id to store when a consent user makes the call:
- the caller: store the consent user's own id. This suits audit records of who acted, and the rows that carry a Consent's own permissions, which are meant to end with it.
- the User acted for: store the id of the User who granted the Consent, so the row outlives the Consent. A few tables keep both ids, for example a counterparty or a transaction request, where who made the call is worth keeping too; the Note column says so.
- refused: a consent user must not do this at all, because what it would create (a Consent, a Consumer or a token) outlives the Consent and would let the delegation extend itself.
A request that explicitly names a consent user as the target User, for example in a user_id field or a USER_ID path segment, is answered with 400 InvalidUserId.
Where a v7.0.0 response publishes both ids, it names them user_id and on_behalf_of_user_id, as in the record metadata of a here record.
Every column that stores a user id
Policy is whose id the column should hold when a consent user makes the call. Today is whose id it holds on this instance now. Of the 73 columns whose policy is "the User acted for" or "refused", 13 apply it today. "Not written by a consent user" means no API call writes that column with a consent user's id, because the endpoint refuses the consent user first or no endpoint writes the table.
| Record | Column | Policy | Today | Note |
|---|---|---|---|---|
| AccountAccess | user_fk |
the caller | the caller | views copied from the consent JWT each request; has lifecycle GC |
| Entitlement | mUserId |
the caller | the caller | only when createdByProcess == consent_user: the consent engine copying the consent's own scope |
| Entitlement | mGrantedByUserId |
the caller | the caller | audit: who granted |
| UserLocks | UserId |
the caller | the caller | lock the authenticated user |
| ExpectedChallengeAnswer | ExpectedUserId |
the caller | the caller | consent and signing-basket authorisation: the caller IS the person authorising, so the challenge is theirs |
| ExpectedChallengeAnswer | ExpectedUserId |
the User acted for | the User acted for | payment SCA: the challenge belongs to the human whose money moves, never to the agent that started the payment |
| ChatMessage | SenderUserId |
the caller | the caller | sender = the authenticated user is truthful |
| Metric | userId |
the caller | the caller | record both: on-behalf-of via consent_reference_id at read time |
| MetricArchive | userId |
the caller | the caller | as Metric_UserId |
| ConnectorTrace | userId |
the caller | the caller | as Metric_UserId |
| DynamicDataAccess | GrantedBy |
the caller | the caller | audit: who granted |
| AuthUser | user |
the caller | the caller | login row -> its own ResourceUser; not attribution |
| OpenIDConnectToken | AuthUserPrimaryKey |
the caller | the caller | token belongs to the login; not attribution |
| UserRefreshes | mUserId |
the caller | the caller | operational: refresh of the authenticated user's own account list |
| PlatformApp | MarkedByUserId |
the caller | the caller | audit: who marked the Consumer as a platform app |
| GroupMembership | CreatedByUserId |
the caller | the caller | audit: who added the member, as Entitlement_GrantedByUserId |
| AssetStatusHistory | ChangedByUserId |
the caller | the caller | audit: who changed the asset's status, the question a regulator asks |
| TransactionRequest | mUserId, mOnBehalfOfUserId |
the User acted for | the User acted for | record both: mUserId = userId, mOnBehalfOfUserId = onBehalfOfUserId |
| Entitlement | mUserId |
the User acted for | the User acted for | the role holder; the consent-engine case is Entitlement_UserId_ConsentScope |
| GroupMembership | UserId |
the User acted for | not written by a consent user | the member, who holds the Roles the Group grants; as Entitlement_UserId |
| AccountHolders | user |
the User acted for | the User acted for | the human holds the account; one held by a per-consent identity strands when the consent dies |
| UserCustomerLink | mUserId |
the User acted for | the User acted for | a Customer is linked to a human; a link on an agent identity dies with its Consent |
| AccountApplication | mUserId |
the User acted for | the caller | explicit target: user_id comes from the request and is guarded at the endpoint, so the provider redirect is unreachable -- see ON_BEHALF_OF_USER_ID_PLAN.md row 14 |
| AccountAccessRequest | RequestorUserId |
the User acted for | the caller | who asked for access; the request outlives the session it was made in |
| AccountAccessRequest | TargetUserId |
the User acted for | not written by a consent user | explicit target: a consent user named here is rejected at the endpoint |
| AccountAccessRequest | CheckerUserId |
the User acted for | the caller | who approved; maker/checker evidence has to name a human |
| DynamicChangeRequest | RequestorUserId |
the User acted for | the caller | maker of a dynamic-code change |
| DynamicChangeRequest | CheckerUserId |
the User acted for | the caller | checker; must differ from the requestor |
| EntitlementRequest | mUserId |
the User acted for | the caller | who asked for the role; the grant that follows lands on a human |
| UserScope | mUserId |
the User acted for | the caller | the scope holder |
| ApiCollection | UserId |
the User acted for | the caller | the user's own saved collection, created through POST /my/api-collections |
| UserAttribute | UserId |
the User acted for | the caller | the user's own attribute, created through the /my/ endpoints |
| UserAgreement | UserId |
the User acted for | the caller | the user's own acceptance of terms |
| UserInitAction | UserId |
the User acted for | the caller | the user's own onboarding action |
| UserAuthContext | mUserId |
the User acted for | the caller | consent copies the on-behalf-of user's contexts into ConsentAuthContext separately |
| UserAuthContextUpdate | mUserId |
the User acted for | the caller | as UserAuthContext_UserId |
| DynamicEntity | UserId |
the User acted for | the User acted for | the definition's creator; a definition outlives the Consent that created it |
| DynamicData | UserId |
the User acted for | the User acted for | personal rows, and the one reference where the redirect MUST be symmetric: MapppedDynamicDataProvider resolves on save/update/get/delete alike, because a row keyed by this column on both sides is otherwise written by an agent and then invisible to it |
| DynamicData | CreatedByUserId, CreatedByOnBehalfOfUserId |
the User acted for | the User acted for | record both: who made the call that created the record, agent included, and the person it was made for; published in the v7.0.0 record metadata |
| DynamicData | UpdatedByUserId, UpdatedByOnBehalfOfUserId |
the User acted for | the User acted for | record both: as DynamicData_CreatedByUserId, for the call that last saved the record |
| DynamicDataAccess | UserId |
the User acted for | the caller | row-level ACL. Deliberately still on the consent user today, because the bootstrap grant and the allows check have to agree with each other -- rows strand, nothing leaks. A later Phase 2 row; see the plan |
| DynamicEndpoint | UserId |
the User acted for | the caller | the dynamic endpoint's creator; outlives the Consent |
| DynamicResourceDoc | CreatedByUserId, UpdatedByUserId |
the User acted for | the caller | and UpdatedByUserId; a dynamic artefact outlives the Consent that created it |
| DynamicMessageDoc | CreatedByUserId, UpdatedByUserId |
the User acted for | the caller | and UpdatedByUserId; a dynamic artefact outlives the Consent that created it |
| ConnectorMethod | CreatedByUserId, UpdatedByUserId |
the User acted for | the caller | and UpdatedByUserId; a dynamic artefact outlives the Consent that created it |
| AbacRule | CreatedByUserId, UpdatedByUserId |
the User acted for | the caller | and UpdatedByUserId; an access rule outlives the Consent that created it |
| Counterparty | mCreatedByUserId, mCreatedByOnBehalfOfUserId |
the User acted for | the User acted for | record both: a counterparty controls where money may be sent, so the actor stays on mCreatedByUserId (and is published as created_by_user_id) while the human goes to mCreatedByOnBehalfOfUserId |
| CounterpartyWhereTag | user |
the User acted for | the caller | who tagged the counterparty's location |
| ApiProductSubscription | CreatedByUserId |
the User acted for | the caller | a subscription outlives the Consent that took it out |
| DynamicGlossaryItem | CreatedByUserId |
the User acted for | the caller | outlives the Consent that created it |
| DomainApi | CreatedByUserId |
the User acted for | the User acted for | the Domain API's creator; a published API outlives the Consent that created it |
| DynamicEntityWebhook | CreatedByUserId, CreatedByOnBehalfOfUserId |
the User acted for | the User acted for | record both, like Counterparty: a webhook sends data out of OBP and outlives the Consent that made it, so the agent stays on CreatedByUserId (published as created_by_user_id) and the person it acted for goes to CreatedByOnBehalfOfUserId |
| Bank | CreatedByUserId |
the User acted for | the User acted for | creator grant already resolved at the endpoint |
| Organisation | CreatedByUserId |
the User acted for | the caller | outlives the Consent that created it |
| Asset | CreatedByUserId |
the User acted for | not written by a consent user | outlives the Consent that created it, as RoutingScheme_CreatedByUserId; seeded rows hold system:asset-seed |
| File | UserId, OnBehalfOfUserId |
the User acted for | not written by a consent user | record both: UserId = who uploaded (the agent), OnBehalfOfUserId = the human, who owns the file |
| FileAttachment | UserId, OnBehalfOfUserId |
the User acted for | not written by a consent user | record both: UserId = who attached (the agent), OnBehalfOfUserId = the human |
| FileAccess | GrantedByUserId, OnBehalfOfUserId |
the User acted for | not written by a consent user | record both: GrantedByUserId = who granted (the agent), OnBehalfOfUserId = the file's owner it acted for |
| FileAccess | GranteeUserId |
the User acted for | not written by a consent user | explicit target: the User given access is named in the request; access given to a consent user would die with its Consent |
| PayeeLookup | CreatedByUserId |
the User acted for | the caller | outlives the Consent that created it |
| RoutingScheme | CreatedByUserId |
the User acted for | the caller | outlives the Consent that created it |
| UtilityPaymentCallback | CreatedByUserId |
the User acted for | the caller | outlives the Consent that created it |
| StandingOrder | UserId |
the User acted for | the caller | the payer; a standing order keeps executing long after any Consent expires |
| DirectDebit | UserId |
the User acted for | the caller | the payer; a mandate keeps executing long after any Consent expires |
| Mandate | CreatedByUserId, UpdatedByUserId |
the User acted for | the caller | and UpdatedByUserId; a mandate authorises payment and outlives the Consent that created it |
| SignatoryPanel | UserIds |
the User acted for | the caller | list of user ids |
| AccountWebhook | mCreatedByUserId |
the User acted for | the caller | published as created_by_user_id on v3.1.0 and v4.0.0; nothing reads it as an ownership key, so the human is not locked out -- but a webhook outlives the Consent that made it and keeps sending account events, so the human behind it should be recorded. Record-both, like Counterparty. See todo/webhook_attribution.md |
| SystemAccountNotificationWebhook | CreatedByUserId |
the User acted for | the caller | as AccountWebhook_CreatedByUserId |
| BankAccountNotificationWebhook | CreatedByUserId |
the User acted for | the caller | as AccountWebhook_CreatedByUserId |
| ChatRoom | CreatedByUserId |
the User acted for | the caller | Portal chat: a human's room |
| Participant | UserId |
the User acted for | the caller | the person in the room |
| Reaction | UserId |
the User acted for | the caller | the person who reacted |
| ChatEmailDigestState | UserId |
the User acted for | the caller | the person the digest is for |
| ChatMessage | MentionedUserIds |
the User acted for | the caller | explicit targets, humans by construction |
| CrmEvent | mUserId |
the User acted for | the caller | the user the event concerns |
| KycCheck | user |
the User acted for | the caller | the customer's user |
| KycCheck | mStaffUserId |
the User acted for | the caller | staff = human operator |
| KycDocument | user |
the User acted for | the caller | the customer's user |
| KycStatus | user |
the User acted for | the caller | the customer's user |
| SocialMedia | user |
the User acted for | the caller | the customer's user |
| CustomerMessage | user |
the User acted for | the caller | the customer's user |
| Meeting | mCustomerUserId |
the User acted for | the caller | the customer side of the meeting |
| Meeting | mStaffUserId |
the User acted for | the caller | staff = human operator |
| Tag | user |
the User acted for | the caller | the author of the annotation; it outlives the Consent |
| WhereTag | user |
the User acted for | the caller | the author of the annotation; it outlives the Consent |
| TransactionImage | user |
the User acted for | the caller | the author of the annotation; it outlives the Consent |
| Consent | mUserId |
refused | the caller (not refused) | a consent user creating a consent = nested delegation |
| Consumer | createdByUserId |
refused | the caller (not refused) | credentials outlive the consent |
| Token | userForeignKey |
refused | the caller (not refused) | credentials outlive the consent |