Skip to content

Commit fd749a4

Browse files
feat(audit): record the administrator on writes made while impersonating a user (#30800)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D2wBtp9QdSCM6fjWYgVvAPi)_ Fixes #30213 Clause-②: yes (widening) Metadata-protocol: added ## 维护者速读 - 改了什么:管理员模拟员工时做的每一次写入,审计日志(`sys_audit_log.metadata.impersonated_by`)和记录动态(`sys_activity`)都记下这位管理员;记录详情页的动态与字段历史显示「Dev Admin 代王工操作」。数据归属、`created_by`、权限仍是员工本人,没有加列、没有迁移。 - 为什么改:裁决 A(「A」)。之前模拟会话里的写入与员工本人的写入在审计上完全一样,事后查不出是谁做的。 - 风险与回滚:新字段只用于记录,授权路径不读它;回滚即还原本 PR,老数据不受影响(老行本来就没有这个键)。评论是分岔:评论不写任何审计/动态行,本 PR 没有改,交您定夺(见下方 Acceptance notes)。 - 先看:`packages/core/src/security/resolve-authz-context.ts`(从会话读出管理员)、`packages/plugins/plugin-audit/src/audit-writers.ts`(写入审计与动态的那几行)、`packages/services/service-automation/src/run-resume.ts`(审批人接手后不再沿用管理员)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-audit exec vitest run src/activity-actor-name.test.ts`,预期 15 个用例全绿,其中 5 个是本卡的(审计行带 `impersonated_by`、动态行带管理员名、员工本人写入不带、钩子里的调用者仍是员工)。 - 需要你做的:评论那一处的分岔选一个方向(见 Acceptance notes 的 needs_decision 三个选项);其余无,席位落地(本 PR 触 `packages/spec`,落地前欠一次契约级复核)。 ## What changes An administrator who impersonates a user writes as that user. Before this PR nothing on the write said otherwise: the `sys_audit_log` row (`user_id` and `actor` both the user, `metadata` empty), the `sys_activity` row and the record were byte-identical to the user's own work. Only the sign-in row named the administrator. This PR carries the administrator as an attribution-only fact, in the additive shape an agent's write already uses (`performed_by` / `on_behalf_of`): - **Spec.** `ExecutionContext.impersonatedBy` (optional string, a `sys_user` id) and `HookContext.provenance.impersonatedBy`, both documented as attribution only. `AutomationContext.impersonatedBy` lets a flow started by such a write carry it. - **Request identity.** `resolveAuthzContext`'s session arm reads `session.impersonatedBy` off the same session read that admitted the caller (session credential only; an API key carries none). `assembleExecutionContext` emits it on both entries (never on a guest envelope). Every transport that uses the shared resolver (REST, the runtime dispatcher, MCP) gets it from that one place. - **Engine.** `buildProvenance` copies it into the hook context's `provenance`, never into `session`. The sandbox body runner projects it like the other provenance members. - **Audit writers.** The CRUD mirror stamps `sys_audit_log.metadata.impersonated_by` and adds `impersonated_by` plus `impersonated_by_name` (the administrator's name, from the same memoized `sys_user` read as `actor_name`) to the `sys_activity` row's metadata. The `action_invocation` row stamps `impersonated_by` too. Each key appears only on such a write, so its absence means the user acted themselves. `user_id`, `actor` and `actor_id` do not move (ADR-0118 D1/D5). - **Flows started by the write.** The record-change trigger reads `provenance.impersonatedBy` into the run's `AutomationContext`; `resolveRunDataContext` puts it on every data node's context on both `runAs` arms; it survives suspension with the persisted context. When an approval decision hands the run to a decider, the decider's segment drops it (they act in their own name). - **Console.** The record timeline (`record:activity`, the console record page's merged feed), `record:history` and the record page's field history (from `sys_audit_log`) read 「Dev Admin 代王工操作」: new key `detail.impersonatedActor` in all ten console languages. An administrator whose name the writer could not read shows as the unknown-user text, never as "System" or a raw id. What the audit reader shows now, for a ticket created by Dev Admin impersonating 王工: `sys_audit_log` row `user_id = 王工`, `actor = 王工`, `metadata = {"impersonated_by": "<Dev Admin's id>"}`; `sys_activity` row `actor_id = 王工`, `actor_name = 王工`, metadata carrying `impersonated_by` and `impersonated_by_name: "Dev Admin"`; the record's `created_by` stays 王工; the timeline line reads 「Dev Admin 代王工操作」. ### The ruling's verify list (comment 6647756733), item by item - **Every write door stamps it.** - Generic data route: yes, through the shared resolver every transport uses. - Actions: yes. The action body's context spreads the caller's envelope (`buildActionExecutionContext`), so its writes carry it; the `action_invocation` row carries it. - Flows started by the write: yes, on both `runAs` arms, until a decider takes over. - **Comments: no. This is a fork, reported, not narrowed** (see Acceptance notes). - **The fact does not reach a caller-gating hook as the caller.** Yes, pinned: a `beforeInsert` hook in an impersonated write sees `session.userId` = the user, nothing naming the administrator in `session`, and `provenance = { impersonatedBy }`. - **Console display is new work.** Built for the activity rows and the audit-log field history. The shared rendering does not also show agent writes (`performed_by`); left out to keep this PR to the ruling. - **Sub-point: everyone who can read the record sees the administrator on the timeline.** Yes. `sys_activity` metadata is served to every reader of the row; `sys_audit_log.metadata` is not narrowed (`audit-log-field-redaction.ts`'s note updated to say so). - **Sub-point: fill the empty `ip_address` if cheap.** Not done. The hook context carries no request IP, so filling it means a second envelope field set at every door. That is not cheap, and the ruling says it must not hold the rest. ## Tests - `plugin-audit` `activity-actor-name.test.ts`: five new cases. An impersonated insert stamps `sys_audit_log.metadata.impersonated_by` with `user_id`/`actor` still the user. The `sys_activity` row carries `impersonated_by` and `impersonated_by_name`. The user's own write carries neither key (control). A `beforeInsert` hook sees the user in `session` and the administrator only in `provenance`. `action-invocation-audit.test.ts`: the invocation row stamps it. - `core`: `resolve-authz-context.test.ts` (an impersonation session yields it with the user's grants; the user's own session does not) and `assemble-execution-context.test.ts` (both entries carry it, a guest envelope never does). - `trigger-record-change`: the run's `AutomationContext` carries the write's `provenance.impersonatedBy`. - `service-automation` `resume-decider-identity.integration.test.ts`: data nodes carry it on both `runAs` arms, and the decider's segment after an approval drops it (plus a control). - `cli` `lowered-body-face-enumeration.test.ts`: the provenance member set now includes `impersonatedBy`. - Console: `recordActivityFeed.actorId-12067.test.tsx` (composed actor line, unknown administrator falls back to the unknown-user text, never "System") and `auditHistoryDisplay.test.ts` (`auditImpersonatorId`). - Ablation: deleting the audit stamp line in `audit-writers.ts` turns the ledger pin red (1 failed / 14 passed); restored, blob equal to HEAD. ## Acceptance notes - **needs_decision: comments.** `sys_comment` is in plugin-audit's `SKIP_OBJECTS`, so a comment writes no `sys_audit_log` and no `sys_activity` row at all, impersonated or not, and its `author_id` is the impersonated user. The ruling's verify list names comments as a write door, so this is reported, not narrowed. Options: - **(a)** Stop skipping `sys_comment` in the audit mirror, so a comment gets the same ledger and activity rows (and the stamp) as any write. Widest change: every comment starts writing two rows. - **(b)** Stamp the fact on the comment row itself. `sys_comment` has no metadata or JSON field today, so this needs a new column and a migration, which ruling A excludes; it would need its own ruling. - **(c)** Leave comments as they are and record it as a known gap in the ruling's follow-up. ⛔ No column and no ledger row was added for comments in this PR. - Agent writes (`performed_by`) are not shown by the new console line; out of the ruling's scope. - `ip_address` is not filled (see the verify list). - Not re-measured against a live deployment; the evidence is the unit and integration pins above. - Touches `packages/spec` (widening, optional members only): a contract-review-tier review is owed before the queue. ## Verification - Full `turbo build` 119/119 on the merge head; `typecheck` exit 0 for spec, core, objectql, runtime, plugin-audit, trigger-record-change, service-automation, `@object-ui/plugin-detail`, `@object-ui/i18n`, `@object-ui/app-shell`. - Package suites: plugin-audit 30, core 341, trigger-record-change 52, service-automation 26, console 3091 + record-detail view 651 (102/102 files), all green. - `dispatch-gates.mjs --ran`: 116 derived families, 116 run, 0 NOT-MEASURED. `check-plugin-teardown-shape --self-test` needed its base commit fetched in the shallow clone; exit 0 after that (48 cases). - `spec` `gen:docs` regenerated `content/docs/references/{data/data-engine,data/hook,kernel/execution-context}.mdx`. Changed lines: 45 files, 654 insertions, 28 deletions (682, under 3000). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cxMph1oXW6ujSSVm1G <!-- signed: domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2a2b76 commit fd749a4

48 files changed

Lines changed: 802 additions & 28 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/plugin-audit': patch
7+
'@objectstack/trigger-record-change': patch
8+
'@objectstack/service-automation': patch
9+
'@object-ui/plugin-detail': patch
10+
'@object-ui/i18n': patch
11+
---
12+
13+
feat(audit): a write made while an administrator impersonates a user now records the administrator
14+
15+
Clause-②: yes (widening)
16+
Metadata-protocol: added
17+
18+
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.
19+
20+
- `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.
21+
- `userId`, `created_by`, ownership, `current_user.*` RLS and every permission stay the impersonated user's. Nothing in the authorization path reads the new field.
22+
- 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.
23+
- 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.
24+
- The console's record timeline and field history read 「Dev Admin 代王工操作」 (`detail.impersonatedActor`, in all ten console languages).
25+
26+
No schema column and no migration: older rows simply carry no key.

‎content/docs/references/data/data-engine.mdx‎

Lines changed: 12 additions & 0 deletions
Large diffs are not rendered by default.

‎content/docs/references/data/hook.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
105105
| **submitted** | `Record<string, any>` | optional | What the caller submitted, as sent (update only) — diagnostics only, never the persist image |
106106
| **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) |
107107
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
108-
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
108+
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string; impersonatedBy?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
109109
| **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. |
110110
| **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. |
111111
| **transaction** | `any` | optional | Database transaction handle |
@@ -145,6 +145,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
145145
| **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. |
146146
| **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. |
147147
| **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. |
148+
| **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. |
148149

149150
### Nested Shape: `HookContext.user`
150151

‎content/docs/references/kernel/execution-context.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const result = AuthGateCodeSchema.parse(data);
6565
| **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | |
6666
| **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. |
6767
| **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. |
68+
| **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. |
6869
| **permissions** | `string[]` | optional (default: `[]`) | |
6970
| **systemPermissions** | `string[]` | optional | |
7071
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |

‎packages/cli/test/lowered-body-face-enumeration.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,8 @@ describe('the lowered body face, against the in-process HookContext (#22837)', (
280280
expect(body.get('dispatch')).toEqual(new Set(['mode', 'index']));
281281
expect(body.get('session')).toBe('passthrough');
282282
// [#25905] Carried member by member, so a member the spec adds to it later is seen as absent.
283-
expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId']));
284-
expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId']));
283+
expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId', 'impersonatedBy']));
284+
expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId', 'impersonatedBy']));
285285
// A tombstone is no member: `session.roles` is `retiredKey(…)`.
286286
expect(inProcess.get('session')).not.toContain('roles');
287287
expect(absent.length).toBeGreaterThan(0);

‎packages/core/src/security/assemble-execution-context.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,3 +629,26 @@ describe('a guest request is a named caller, never an empty context', () => {
629629
});
630630
}
631631
});
632+
633+
describe('impersonatedBy — the administrator of an impersonation session, carried as attribution only', () => {
634+
const IMPERSONATED: ResolvedAuthzContext = { ...HUMAN_MINIMAL, impersonatedBy: 'u_admin' };
635+
const assembly = (authz: ResolvedAuthzContext) => ({
636+
authz, oauth: undefined, localization: undefined, requestLocale: undefined, accessToken: undefined, authGate: undefined,
637+
});
638+
639+
it('both entries carry it, beside a subject that stays the impersonated user', () => {
640+
for (const ctx of [assembleExecutionContext(assembly(IMPERSONATED)), assembleExecutionContextOrGuest(assembly(IMPERSONATED))]) {
641+
expect(ctx?.impersonatedBy).toBe('u_admin');
642+
expect(ctx?.userId).toBe(HUMAN_MINIMAL.userId);
643+
expect(ctx?.positions).toEqual(HUMAN_MINIMAL.positions);
644+
expect(ExecutionContextSchema.parse(ctx).impersonatedBy).toBe('u_admin');
645+
}
646+
});
647+
648+
it('is absent for the user\'s own session and never on a guest envelope', () => {
649+
expect('impersonatedBy' in assembleExecutionContext(assembly(HUMAN_MINIMAL))!).toBe(false);
650+
const guest = assembleExecutionContextOrGuest(assembly({ ...ANONYMOUS, impersonatedBy: 'u_admin' }));
651+
expect(guest?.principalKind).toBe('guest');
652+
expect('impersonatedBy' in guest!).toBe(false);
653+
});
654+
});

‎packages/core/src/security/assemble-execution-context.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [
147147
'principalKind',
148148
'onBehalfOf',
149149
'performedBy',
150+
'impersonatedBy',
150151
'audience',
151152
'userId',
152153
'tenantId',
@@ -354,6 +355,13 @@ function entryFields(
354355
// what narrows the optional away — the guard that made `agent` truthy is
355356
// this same property.
356357
performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined,
358+
// The administrator who started the impersonation session that admitted
359+
// `userId` (the resolver's session arm reads `session.impersonatedBy`).
360+
// Attribution, not authorization, in the shape `performedBy` uses: the
361+
// subject stays the impersonated user above, whose grants the envelope
362+
// carries, and no security middleware reads this. An anonymous entry has
363+
// no session to have been minted for anyone.
364+
impersonatedBy: anonymous ? undefined : authz.impersonatedBy,
357365
// [ADR-0090 D10/D11 · ruling of record 6355206139, letter B] The resolver's
358366
// population stamp: `'external'` for a holder of `org_external`, absent
359367
// (read as 'internal') otherwise. It rides an AGENT envelope too: the

‎packages/core/src/security/resolve-authz-context.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,31 @@ describe('resolveAuthzContext — single source of truth', () => {
165165
expect(ctx.permissions).toContain('ai_seat');
166166
});
167167

168+
it('carries the impersonating administrator off the session read, and resolves the impersonated user\'s grants', async () => {
169+
const ql = makeQl({
170+
sys_member: [{ user_id: 'u1', organization_id: 'o1', role: 'member' }],
171+
sys_user_position: [],
172+
});
173+
const impersonation = async () => ({
174+
user: { id: 'u1' },
175+
session: { userId: 'u1', activeOrganizationId: 'o1', impersonatedBy: 'u_admin' },
176+
});
177+
const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: impersonation });
178+
expect(ctx.userId).toBe('u1');
179+
expect(ctx.credential).toBe('session');
180+
expect(ctx.impersonatedBy).toBe('u_admin');
181+
expect(ctx.positions).toContain('org_member');
182+
183+
// Control: the user's own session names no administrator; a null mark is no mark.
184+
const own = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
185+
expect('impersonatedBy' in own).toBe(false);
186+
const cleared = await resolveAuthzContext({
187+
userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(),
188+
getSession: async () => ({ user: { id: 'u1' }, session: { activeOrganizationId: 'o1', impersonatedBy: null } }),
189+
});
190+
expect('impersonatedBy' in cleared).toBe(false);
191+
});
192+
168193
it('anonymous (no session, no api key) → empty context', async () => {
169194
const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined });
170195
expect(ctx.userId).toBeUndefined();

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,19 @@ export interface ResolvedAuthzContext {
159159
* ({@link authGate}), never by a cookie that rode along beside the key.
160160
*/
161161
credential?: 'api_key' | 'session';
162+
/**
163+
* The administrator who started the impersonation session that admitted
164+
* {@link userId} — `session.impersonatedBy` off the same session read
165+
* (better-auth's admin plugin; `sys_session.impersonated_by`). Set only when
166+
* {@link credential} is `'session'`; absent for an API key and for every
167+
* session the user started themselves.
168+
*
169+
* ATTRIBUTION ONLY: carried onto `ExecutionContext.impersonatedBy` so the
170+
* audit writer can name the person at the keyboard. Nothing in this
171+
* resolver's grant aggregation reads it — {@link userId} stays the
172+
* impersonated user, whose grants the request resolves.
173+
*/
174+
impersonatedBy?: string;
162175
/**
163176
* [ADR-0069 · #23287, ruling C′ item 3] The principal's authentication-policy
164177
* posture, decided once here for whichever credential admitted it:
@@ -604,6 +617,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
604617
? admission.principal
605618
: undefined;
606619
let credential: ResolvedAuthzContext['credential'];
620+
let impersonatedBy: string | undefined;
607621
let authGate: AuthGate | undefined = admission.outcome === 'held' ? admission.gate : undefined;
608622
if (keyPrincipal) {
609623
userId = keyPrincipal.userId;
@@ -624,6 +638,10 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
624638
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
625639
if (userId) {
626640
credential = 'session';
641+
// The impersonating administrator, off the same read that admitted the
642+
// session. Attribution only — the grants below stay `userId`'s.
643+
const by = sessionData?.session?.impersonatedBy;
644+
if (typeof by === 'string' && by.trim()) impersonatedBy = by.trim();
627645
// [ADR-0069 · #23287] The session's posture, off the same read that
628646
// admitted it — so a door asks one field for either credential.
629647
authGate = normalizeAuthGate(sessionData?.user) ?? undefined;
@@ -651,6 +669,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
651669
ctx.userId = userId;
652670
if (tenantId) ctx.tenantId = tenantId;
653671
if (credential) ctx.credential = credential;
672+
if (impersonatedBy) ctx.impersonatedBy = impersonatedBy;
654673
if (authGate) ctx.authGate = authGate;
655674
if (!ql || typeof ql.find !== 'function') return ctx;
656675

‎packages/objectql/src/engine-execution-plumbing.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,23 @@ export function buildSession(execCtx?: ExecutionContext): HookContext['session']
132132
* `provenance` value is exactly what this function returns, so a key
133133
* declared on `hook.zod.ts` and missing here is declared and never
134134
* populated (ADR-0049).
135+
*
136+
* `impersonatedBy` rides it for the FOURTH time: an impersonation session's
137+
* write authorizes as the impersonated user, so the administrator at the
138+
* keyboard reaches the audit writer here and never through `session`, where
139+
* every caller-gating hook would read the administrator as the caller.
135140
*/
136141
export function buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] {
137142
const flowRunId = (execCtx as any)?.flowRunId;
138143
const attributedUserId = (execCtx as any)?.attributedUserId;
139144
const performedByClientId = (execCtx as any)?.performedBy?.clientId;
140-
if (!flowRunId && !attributedUserId && !performedByClientId) return undefined;
145+
const impersonatedBy = (execCtx as any)?.impersonatedBy;
146+
if (!flowRunId && !attributedUserId && !performedByClientId && !impersonatedBy) return undefined;
141147
return {
142148
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
143149
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
144150
...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}),
151+
...(impersonatedBy ? { impersonatedBy: String(impersonatedBy) } : {}),
145152
};
146153
}
147154

0 commit comments

Comments
 (0)