Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/17022-agent-dual-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/objectql': minor
'@objectstack/plugin-audit': minor
---

Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution

A `sys_audit_log` row written by an MCP OAuth client acting for a human used to
be byte-identical to a row that human wrote in the Console. The envelope carried
the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and
nothing in between copied it: `assembleExecutionContext` consumed the OAuth
`azp` as a boolean and dropped the value, so the acting client did not exist
downstream of the door at all.

The delegation now travels the whole way and lands on the row:

- `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth
door, on the same branch that already decides `principalKind: 'agent'` and
`onBehalfOf`; a member of the closed entry field set like every other.
- `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside
`flowRunId` and `attributedUserId`. Provenance, not `session`: no
caller-gating hook may read the client as the caller.
- `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated
write, and nothing at all on a personal one — the two shapes are told apart by
absence rather than by guesswork.

Additive, and attribution only. `user_id` stays the human, so owner-stamping,
`current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 —
attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps
that column two-valued — a user id, or `null` for the system — and answers
"which non-user acted" with an added attribution field rather than a second
actor vocabulary. No existing row changes meaning, and no historical row is
rewritten.

Rule 4's third element, the run id, is NOT delivered here and is not declared
either: nothing on the request path mints one today (`ExecutionContext.traceId`
is declared but resolved by no transport entry point), and declaring a carrier
nothing populates is the defect this change exists to close.
12 changes: 12 additions & 0 deletions content/docs/references/data/data-engine.mdx

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| **submitted** | `Record<string, any>` | optional | What the caller submitted, as sent (update only) — diagnostics only, never the persist image |
| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record<string, any> }` | optional | How this hook call relates to the caller's write (engine-produced) |
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. |
| **transaction** | `any` | optional | Database transaction handle |
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
Expand Down Expand Up @@ -132,6 +132,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| :--- | :--- | :--- | :--- |
| **flowRunId** | `string` | optional | Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request. |
| **attributedUserId** | `string` | optional | The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as. |
| **performedByClientId** | `string` | optional | [ADR-0090 D10 rule 4] The AGENT that performed this write — the OAuth client (`azp`, a registered sys_oauth_application id) the access token was issued to, when an AI client acted on behalf of the human in `session.userId`. Copied from `ExecutionContext.performedBy.clientId`, which only the /mcp OAuth door sets. ATTRIBUTION ONLY, exactly like `attributedUserId`: the audit writer records it in `sys_audit_log.metadata` as `performed_by` beside `on_behalf_of`; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every non-delegated write, so a hook tells the two apart by the key being there rather than by guesswork. |

### Nested Shape: `HookContext.user`

Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const result = ExecutionContextSchema.parse(data);
| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. |
| **authGate** | `{ code: string; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on (PASSWORD_EXPIRED / MFA_REQUIRED) and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. |
| **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | |
| **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. |
| **permissions** | `string[]` | optional (default: `[]`) | |
| **systemPermissions** | `string[]` | optional | |
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ directory rather than per file.
| `api/` | 451 |
| `identity/` | 32 |
| `integration/` | 8 |
| `kernel/` | 256 |
| `kernel/` | 257 |
| `marketplace/` | 29 |
| `qa/` | 6 |
| `shared/` | 20 |
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/security/assemble-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,27 @@ describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-#
authGate: undefined,
});
const before = legacyDispatcherAssembly(authz, oauth, localization, requestLocale);
expect(observable(now)).toEqual(observable(before));

// [#17022] The ONE argued delta from the frozen transcription, and
// it is SUBTRACTED here rather than absorbed into the transcription
// itself — the header's rule is that a legacy function is never
// "kept up to date", because the day it needs editing is the day a
// face's output changed and the change owes its own argument.
// Here that argument is ADR-0090 D10 rule 4: the agent face now
// carries the identity of the client that performed the write, and
// no other face carries anything new.
//
// Subtraction alone would be a hole big enough to hide the next
// drift in, so the key is asserted POSITIVELY on the very next
// line: present exactly on the agent branch — an authenticated
// principal whose token names a client — absent everywhere else,
// and carrying that client's id. The parity pin below is otherwise
// unweakened: every other key still compares byte-for-byte.
const { performedBy, ...restOfNow } = observable(now);
expect(restOfNow).toEqual(observable(before));
expect(performedBy).toEqual(
authz.userId && oauth?.clientId ? { clientId: oauth.clientId } : undefined,
);
});
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/security/assemble-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [
'isSystem',
'principalKind',
'onBehalfOf',
'performedBy',
'audience',
'userId',
'tenantId',
Expand Down Expand Up @@ -315,6 +316,19 @@ function entryFields(
isSystem: false,
principalKind: agent ? 'agent' : anonymous ? 'guest' : 'human',
onBehalfOf: agent ? { userId: authz.userId!, principalKind: 'human' } : undefined,
// [ADR-0090 D10 rule 4 — dual attribution] The same `azp` that made this
// an agent principal, carried as the PERFORMER so the write is attributable
// to the client and not only to the human it acts for. Until now the value
// was consumed as a boolean here and dropped: `onBehalfOf` recorded WHO was
// delegated FROM, and nothing downstream could name WHO acted, which left
// an agent's `sys_audit_log` row byte-identical to the human's own (#17022).
//
// Attribution, not authorization: `userId` still stays the human above, the
// ceiling still travels on `permissions`/`systemPermissions`, and no
// security middleware reads this. `agent?.clientId` rather than `agent` is
// what narrows the optional away — the guard that made `agent` truthy is
// this same property.
performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined,
// [ADR-0090 D10/D11 — P1 shape] No transport resolves an external
// (portal/partner) audience yet; `undefined` reads as 'internal'. Named
// here rather than excluded so the gap is visible in the closed set instead
Expand Down
15 changes: 14 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4100,14 +4100,27 @@ export class ObjectQL implements IObjectQLEngine {
* triggered it must reach the audit writer WITHOUT appearing in `session` —
* where every caller-gating hook would read them as the caller. Attribution
* here, authorization in `session`/`isSystem`, never the two mixed.
*
* `performedByClientId` rides it for the THIRD time on the same reasoning
* (#17022, ADR-0090 D10 rule 4): an MCP OAuth agent's write authorizes as
* the human it acts for, so the acting client can only reach the audit
* writer through a channel that is not `session` — putting it there would
* make every caller-gating hook read the client as the caller, which is the
* opposite of what the delegation means. This copy list is the whole reason
* the field is not inert: `HookContext` is built from a CLOSED literal whose
* `provenance` value is exactly what this function returns, so a key
* declared on `hook.zod.ts` and missing here is declared and never
* populated (ADR-0049).
*/
private buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] {
const flowRunId = (execCtx as any)?.flowRunId;
const attributedUserId = (execCtx as any)?.attributedUserId;
if (!flowRunId && !attributedUserId) return undefined;
const performedByClientId = (execCtx as any)?.performedBy?.clientId;
if (!flowRunId && !attributedUserId && !performedByClientId) return undefined;
return {
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}),
};
}

Expand Down
Loading
Loading