Skip to content
Merged
26 changes: 26 additions & 0 deletions .changeset/30213-impersonation-write-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/runtime': patch
'@objectstack/plugin-audit': patch
'@objectstack/trigger-record-change': patch
'@objectstack/service-automation': patch
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

feat(audit): a write made while an administrator impersonates a user now records the administrator

Clause-②: yes (widening)
Metadata-protocol: added

An administrator who impersonates a user writes as that user, and until now nothing on the write said otherwise: the `sys_audit_log` row, the `sys_activity` row and the record were byte-identical to the user's own work. The session already knew (`sys_session.impersonated_by`); the write did not.

- `ExecutionContext.impersonatedBy` (new, optional, attribution only) carries the administrator's `sys_user` id. The shared request-identity resolution sets it from the session read that admitted the caller, for a session credential only. `HookContext.provenance.impersonatedBy` hands it to hooks; it is never folded into `session`, so a hook that gates on the caller still sees the impersonated user.
- `userId`, `created_by`, ownership, `current_user.*` RLS and every permission stay the impersonated user's. Nothing in the authorization path reads the new field.
- The audit writer stamps `sys_audit_log.metadata.impersonated_by` on record writes and on `action_invocation` rows, and `impersonated_by` plus `impersonated_by_name` in the `sys_activity` row's metadata. Each key is present only on such a write; its absence means the user acted themselves. `user_id` and `actor` do not move.
- A flow started from such a session carries the administrator onto its own writes (`AutomationContext.impersonatedBy`), on both `runAs` arms: a record-triggered flow, a manual `POST /:name/trigger` and a `type: 'flow'` action alike. After an approval decision the decider's segment carries none.
- The console's record timeline and field history read 「Dev Admin 代王工操作」 (`detail.impersonatedActor`, in all ten console languages).

No schema column and no migration: older rows simply carry no key.
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 @@ -105,7 +105,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; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string; impersonatedBy?: 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, tree or user field that references a record being deleted, where a user field takes part only when it declares its deleteBehavior). Absent on every other write, including a user hand-clearing the same field; 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. |
| **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. |
| **transaction** | `any` | optional | Database transaction handle |
Expand Down Expand Up @@ -145,6 +145,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. |
| **impersonatedBy** | `string` | optional | The ADMINISTRATOR (a sys_user id) who made this write from an impersonation session, while `session.userId` is the impersonated user the write is authorized as. Copied from `ExecutionContext.impersonatedBy`, which the shared request-identity resolution sets from the session read. ATTRIBUTION ONLY, exactly like `performedByClientId`: the audit writer records it as `sys_audit_log.metadata.impersonated_by` and on the sys_activity row; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every write the user made themselves. |

### 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 @@ -65,6 +65,7 @@ const result = AuthGateCodeSchema.parse(data);
| **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. |
| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. |
| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. |
| **permissions** | `string[]` | optional (default: `[]`) | |
| **systemPermissions** | `string[]` | optional | |
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/lowered-body-face-enumeration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ describe('the lowered body face, against the in-process HookContext (#22837)', (
expect(body.get('dispatch')).toEqual(new Set(['mode', 'index']));
expect(body.get('session')).toBe('passthrough');
// [#25905] Carried member by member, so a member the spec adds to it later is seen as absent.
expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId']));
expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId']));
expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId', 'impersonatedBy']));
expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId', 'impersonatedBy']));
// A tombstone is no member: `session.roles` is `retiredKey(…)`.
expect(inProcess.get('session')).not.toContain('roles');
expect(absent.length).toBeGreaterThan(0);
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/security/assemble-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,3 +629,26 @@ describe('a guest request is a named caller, never an empty context', () => {
});
}
});

describe('impersonatedBy — the administrator of an impersonation session, carried as attribution only', () => {
const IMPERSONATED: ResolvedAuthzContext = { ...HUMAN_MINIMAL, impersonatedBy: 'u_admin' };
const assembly = (authz: ResolvedAuthzContext) => ({
authz, oauth: undefined, localization: undefined, requestLocale: undefined, accessToken: undefined, authGate: undefined,
});

it('both entries carry it, beside a subject that stays the impersonated user', () => {
for (const ctx of [assembleExecutionContext(assembly(IMPERSONATED)), assembleExecutionContextOrGuest(assembly(IMPERSONATED))]) {
expect(ctx?.impersonatedBy).toBe('u_admin');
expect(ctx?.userId).toBe(HUMAN_MINIMAL.userId);
expect(ctx?.positions).toEqual(HUMAN_MINIMAL.positions);
expect(ExecutionContextSchema.parse(ctx).impersonatedBy).toBe('u_admin');
}
});

it('is absent for the user\'s own session and never on a guest envelope', () => {
expect('impersonatedBy' in assembleExecutionContext(assembly(HUMAN_MINIMAL))!).toBe(false);
const guest = assembleExecutionContextOrGuest(assembly({ ...ANONYMOUS, impersonatedBy: 'u_admin' }));
expect(guest?.principalKind).toBe('guest');
expect('impersonatedBy' in guest!).toBe(false);
});
});
8 changes: 8 additions & 0 deletions packages/core/src/security/assemble-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [
'principalKind',
'onBehalfOf',
'performedBy',
'impersonatedBy',
'audience',
'userId',
'tenantId',
Expand Down Expand Up @@ -354,6 +355,13 @@ function entryFields(
// what narrows the optional away — the guard that made `agent` truthy is
// this same property.
performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined,
// The administrator who started the impersonation session that admitted
// `userId` (the resolver's session arm reads `session.impersonatedBy`).
// Attribution, not authorization, in the shape `performedBy` uses: the
// subject stays the impersonated user above, whose grants the envelope
// carries, and no security middleware reads this. An anonymous entry has
// no session to have been minted for anyone.
impersonatedBy: anonymous ? undefined : authz.impersonatedBy,
// [ADR-0090 D10/D11 · ruling of record 6355206139, letter B] The resolver's
// population stamp: `'external'` for a holder of `org_external`, absent
// (read as 'internal') otherwise. It rides an AGENT envelope too: the
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,31 @@ describe('resolveAuthzContext — single source of truth', () => {
expect(ctx.permissions).toContain('ai_seat');
});

it('carries the impersonating administrator off the session read, and resolves the impersonated user\'s grants', async () => {
const ql = makeQl({
sys_member: [{ user_id: 'u1', organization_id: 'o1', role: 'member' }],
sys_user_position: [],
});
const impersonation = async () => ({
user: { id: 'u1' },
session: { userId: 'u1', activeOrganizationId: 'o1', impersonatedBy: 'u_admin' },
});
const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: impersonation });
expect(ctx.userId).toBe('u1');
expect(ctx.credential).toBe('session');
expect(ctx.impersonatedBy).toBe('u_admin');
expect(ctx.positions).toContain('org_member');

// Control: the user's own session names no administrator; a null mark is no mark.
const own = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
expect('impersonatedBy' in own).toBe(false);
const cleared = await resolveAuthzContext({
userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(),
getSession: async () => ({ user: { id: 'u1' }, session: { activeOrganizationId: 'o1', impersonatedBy: null } }),
});
expect('impersonatedBy' in cleared).toBe(false);
});

it('anonymous (no session, no api key) → empty context', async () => {
const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined });
expect(ctx.userId).toBeUndefined();
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,19 @@ export interface ResolvedAuthzContext {
* ({@link authGate}), never by a cookie that rode along beside the key.
*/
credential?: 'api_key' | 'session';
/**
* The administrator who started the impersonation session that admitted
* {@link userId} — `session.impersonatedBy` off the same session read
* (better-auth's admin plugin; `sys_session.impersonated_by`). Set only when
* {@link credential} is `'session'`; absent for an API key and for every
* session the user started themselves.
*
* ATTRIBUTION ONLY: carried onto `ExecutionContext.impersonatedBy` so the
* audit writer can name the person at the keyboard. Nothing in this
* resolver's grant aggregation reads it — {@link userId} stays the
* impersonated user, whose grants the request resolves.
*/
impersonatedBy?: string;
/**
* [ADR-0069 · #23287, ruling C′ item 3] The principal's authentication-policy
* posture, decided once here for whichever credential admitted it:
Expand Down Expand Up @@ -604,6 +617,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
? admission.principal
: undefined;
let credential: ResolvedAuthzContext['credential'];
let impersonatedBy: string | undefined;
let authGate: AuthGate | undefined = admission.outcome === 'held' ? admission.gate : undefined;
if (keyPrincipal) {
userId = keyPrincipal.userId;
Expand All @@ -624,6 +638,10 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
if (userId) {
credential = 'session';
// The impersonating administrator, off the same read that admitted the
// session. Attribution only — the grants below stay `userId`'s.
const by = sessionData?.session?.impersonatedBy;
if (typeof by === 'string' && by.trim()) impersonatedBy = by.trim();
// [ADR-0069 · #23287] The session's posture, off the same read that
// admitted it — so a door asks one field for either credential.
authGate = normalizeAuthGate(sessionData?.user) ?? undefined;
Expand Down Expand Up @@ -651,6 +669,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
ctx.userId = userId;
if (tenantId) ctx.tenantId = tenantId;
if (credential) ctx.credential = credential;
if (impersonatedBy) ctx.impersonatedBy = impersonatedBy;
if (authGate) ctx.authGate = authGate;
if (!ql || typeof ql.find !== 'function') return ctx;

Expand Down
9 changes: 8 additions & 1 deletion packages/objectql/src/engine-execution-plumbing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,23 @@ export function buildSession(execCtx?: ExecutionContext): HookContext['session']
* `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).
*
* `impersonatedBy` rides it for the FOURTH time: an impersonation session's
* write authorizes as the impersonated user, so the administrator at the
* keyboard reaches the audit writer here and never through `session`, where
* every caller-gating hook would read the administrator as the caller.
*/
export function buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] {
const flowRunId = (execCtx as any)?.flowRunId;
const attributedUserId = (execCtx as any)?.attributedUserId;
const performedByClientId = (execCtx as any)?.performedBy?.clientId;
if (!flowRunId && !attributedUserId && !performedByClientId) return undefined;
const impersonatedBy = (execCtx as any)?.impersonatedBy;
if (!flowRunId && !attributedUserId && !performedByClientId && !impersonatedBy) return undefined;
return {
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}),
...(impersonatedBy ? { impersonatedBy: String(impersonatedBy) } : {}),
};
}

Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/plugin-audit/src/action-invocation-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,21 @@ describe('[#27908] the action_invocation row tells an agent\'s call from a click
expect('target' in JSON.parse(String(row.metadata))).toBe(false);
});

it('a click from an impersonation session names the administrator; the user stays the subject', () => {
const row = buildActionInvocationAuditRow({
...CLICK,
context: { ...CLICK.context, impersonatedBy: 'usr_admin' } as any,
});
expect(row.user_id).toBe('usr_1');
expect(row.actor).toBe('usr_1');
expect(JSON.parse(String(row.metadata))).toEqual({
surface: 'rest', action_name: 'close_case', branch: 'update', outcome: 'succeeded',
impersonated_by: 'usr_admin',
});
// Control: the user's own click claims no impersonation.
expect(JSON.parse(String(buildActionInvocationAuditRow(CLICK).metadata))).not.toHaveProperty('impersonated_by');
});

it('an object-less action names no object and no record', () => {
const row = buildActionInvocationAuditRow({ ...CLICK, objectName: undefined, recordId: 'r1' });
expect(row.object_name).toBeNull();
Expand Down
Loading
Loading