Skip to content

Commit 9038187

Browse files
fix(audit): name the impersonating administrator on identity writes and on an approval decided while impersonating (#30874)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DASH6Wenq1awyqAanJTCYpU)_ Fixes #30851 Clause-②: yes (widening) Metadata-protocol: none domain:server#5 card thread · session `session_016WV4czA9H21XfNGbug1dac` · branch `claude/project-thread-xv0lyn` (named in the seat's re-posted claim on the card, with the widened file surface). Branched from `main` at 677d542; `origin/main` merged at e603385. Item 2 adds one optional member to a spec-owned contract (`ResumeSignal.impersonatedBy`): the dev stopped on it as the dispatch required, and the seat chose route A and one PR for both items. ## 维护者速读 - 改了什么:管理员代登录(模拟)某用户时,两类写入现在也记下真正操作的管理员:① plugin-auth 代 better-auth 写身份表(改名、改邮箱、成员变更、管理员路由如设置上级)时,上下文带上 `impersonatedBy`,审计行 `metadata.impersonated_by` 写管理员;② 审批决定后,流程里决定人那一段只带「这次决定」时的代登录管理员,本人决定则不带。 - 为什么改:#30213(PR #30800)已给记录写入打上代登录标记,契约复核留下两处缺口:身份表写入审计行与用户本人操作无法区分;审批恢复时决定人那段要么沿用触发时的旧标记(本人决定也带),要么代登录决定时完全不带。 - 风险与回滚:只加归属信息,`user_id`、权限、鉴权路径一概不动;spec 只给 `ResumeSignal` 加一个可选成员,且只有带服务标记(HTTP 请求体伪造不了)时才生效;回滚即 revert,无迁移。 - 先看:`packages/services/service-automation/src/run-resume.ts` 的 `deciderSegmentContext`;`packages/plugins/plugin-auth/src/auth-actor-attribution.ts` 的 `authSystemWriteContext` / `resolveAttributedActor`;`packages/plugins/plugin-approvals/src/approval-service.ts` 决定入口取 `impersonatorOf(context)` 那几行。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/resume-decider-identity.integration.test.ts`,预期 32 条全绿,其中「the decider's segment names exactly the DECISION's impersonating administrator」一组覆盖本人决定、代登录决定、普通决定三种情况。 - 需要你做的:无,席位落地(本 PR 触 `packages/spec/src/**`,席位 ACCEPT 前需契约复核)。 ## What changes **1. plugin-auth identity-table writes name the impersonating administrator.** An administrator who impersonates a user and renames them (or makes any other write better-auth performs on the user's behalf) produced a `sys_user` write credited to the user through `attributedUserId` and nothing else, so its audit row read as the user's own. Now: - `resolveActor` (`auth-manager-actor-guards.ts`) also answers the session's `impersonatedBy`, read from the session that `getSessionFromCtx` resolves or from the session row on the token fallback. Never from the request body. - The global before-hook hands the attribution scope `{ userId, impersonatedBy }`. `AuthActorResolver` may now answer that object (a bare string still means the user alone). The new export `resolveAttributedActor()` answers the user and the administrator together, and `resolveAttributedUserId()` is now a projection of it. - `authSystemWriteContext()` adds `impersonatedBy` beside `attributedUserId` (attribution only; `isSystem: true` is untouched and no `userId` appears). The adapter's `withSystemContext` now builds its write context with that same function instead of a second copy of it, so the adapter, the membership reconciler and `set-user-manager` all carry the key. - The platform-admin routes that drive better-auth programmatically (`gateAdmin` → `runAttributedToUser`) pass the admitted actor, which now carries the gated session's `impersonatedBy` (`PlatformAdminActor.impersonatedBy`, optional, set by the route's gate and never by `judgePlatformAdmin`). - The engine's `buildProvenance` already copies `ExecutionContext.impersonatedBy` onto `HookContext.provenance`, and the audit writer already stamps `sys_audit_log.metadata.impersonated_by` from it (PR #30800). The key is the one `auth-session-audit.ts` and #30800 use. **2. The approval decider's segment carries exactly the decision's administrator.** - `ResumeSignal.impersonatedBy` (new, optional, `packages/spec/src/contracts/automation-service.ts`). It is honoured only beside `resumedBy`, on a signal that carries `RESUME_AUTHORITY_SERVICE`, exactly as `resumedBy` is. The `AutomationContext.impersonatedBy` docblock now says what a decision does to it. - The decision door (`ApprovalService`'s decide path, `approval-service.ts`) fills it from the decision request's `ExecutionContext.impersonatedBy` (`impersonatorOf(context)`), only when there is a decider. A stranded decision's journal (`StrandedContinuationSignal`, `__strandedContinuation`) keeps it beside `resumedBy`, and `continueRestoredRun` replays it. - `deciderSegmentContext` (`run-resume.ts`) drops the trigger-time stamp on every human decider's segment and sets the decision's value. That covers (a) the `context.userId === decider` early return, which used to keep the trigger-time stamp, and (b) an impersonated decider, who used to get none. A signal with no decider (an SLA auto-decision) still keeps the run's identity and its stamp. The engine's `resolveDecisionSegmentContext` docblock says so. One changeset, all four packages `patch`. ## Tests - `service-automation/src/resume-decider-identity.integration.test.ts`: new describe "the decider's segment names exactly the DECISION's impersonating administrator, never the trigger's". It covers (a) a trigger-time stamp with the trigger user deciding in their own name → none, on both `runAs` arms; (b) an impersonated decider → the decision's administrator, on both arms, and the trigger user deciding while impersonated on an unstamped run; (c) a plain decider → none, plus a second plain decision dropping the first decision's stamp. CONTROLs: a signal without the service marker cannot name an administrator, and an administrator with no decider changes nothing. - `plugin-approvals/src/restored-approval-continuation.test.ts` (ruling A describe): approve and reject while impersonated carry `impersonatedBy: 'adm'` beside the decider; an administrator named in the decision INPUT stamps nothing; a stranded impersonated decision journals and replays it. The existing plain-decider cases now also assert the key's absence (`toEqual`). - `plugin-auth/src/auth-actor-attribution.test.ts`: the write context carries the administrator; CONTROLs for a plain session, `null` and a blank value; an administrator with no user attributes nothing; `withSystemContext` stamps writes and not reads; `runAttributedToUser` with the actor object. - `plugin-auth/src/admin-impersonate-endpoint.test.ts` (the impersonation harness): on the real better-auth pipeline, the `/update-user` rename made from the impersonation session reaches the engine with `{ isSystem: true, attributedUserId: target, impersonatedBy: admin }`, and a body key `impersonatedBy` is ignored. CONTROL: the administrator's own rename carries no key. <details><summary>Ablations (each red, each restored; <code>scripts/ablation-replace.mjs</code> WRAP mode, <code>git diff HEAD</code> empty after each)</summary> | # | Mutation | Test run | Red | |:--|:--|:--|:--| | A1 | before-hook resolver answers the user id only (`main`'s shape) | `admin-impersonate-endpoint.test.ts` | 1 of 16: the impersonated rename | | A2 | `authSystemWriteContext` drops the `impersonatedBy` spread | `auth-actor-attribution.test.ts` + `admin-impersonate-endpoint.test.ts` | 4 of 34 | | A3 | self-decider path returns the persisted context (`main`'s early return) | `resume-decider-identity.integration.test.ts` | 3 of 32: (a) on both arms, and the trigger user deciding while impersonated | | A4 | the switched segment never sets the decision's value | same | 3 of 32: (b) on both arms, and the second-decision case | | A5 | the decide door does not pass `impersonatedBy` | `restored-approval-continuation.test.ts` | 3 of 24: approve, reject, stranded replay | | A6 | the journal drops `impersonatedBy` | same | 1 of 24: the stranded replay | Every ablated module is imported from `src/` by its test, so no dist rebuild sits between the mutation and the run. </details> ## Acceptance notes - **Reproduction.** Ablation A1 is the reproduction. It turns the before-hook resolver back to its `main` shape (user id only), and the real-pipeline pin then fails: the impersonated rename reaches the engine with no `impersonatedBy`. - **Not done here:** the `sys_comment` fork (a separate maintainer decision on #30213, options A/B/C). The ADR-0043 action-link decision path authenticates by token and has no session, so it carries no administrator, by construction. - **Overlap:** open PR #30717 also edits `auth-manager.ts` and `auth-plugin.ts`, in different hunks. ## Verification All readings were taken on head `b5edf8ea`, or on `8e621657` for the gate sweep. The one commit after `8e621657` edits only `auth-actor-attribution.test.ts`, and `check:query-options-erasure` plus eslint on that file were re-run on `b5edf8ea`. <details><summary>Builds, typecheck, package tests</summary> - Whole-tree `turbo run build --concurrency=2`: 119/119 tasks, exit 0. - `pnpm --filter @objectstack/spec --filter @objectstack/plugin-auth --filter @objectstack/plugin-approvals --filter @objectstack/service-automation run typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: 13 of 14 artifacts current. `check:test-typecheck` failed inside the wrapper with "tsc failed without diagnostics" under the default heap. Run alone with `--max-old-space-size=6144` it is OK (52 files, 240 errors, 131 pinned signatures held). No artifact moved: `ResumeSignal` is a TS interface and appears in no emitted snapshot. - `pnpm test` per package: plugin-auth 228 files, 4096 passed, 10 skipped; service-automation 264 files, 3429 passed; plugin-approvals 115 files, 1502 passed. </details> <details><summary>Gates (dispatch-gates derivation, 88 families + 3 named)</summary> - `node scripts/pm/dispatch-gates.mjs --ran`: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. - The one red in the sweep was `check:query-options-erasure`: the test surface went 236 → 237 from an `as any` on a `find` call in the new attribution pin. The cast is gone in `b5edf8ea`, and the gate now exits 0 at the 236 ceiling. Its "approval-service.ts erasure count fell 10 → 4" line is the same on `main` (both trees hold 52 `as any`), so it is not this diff. - `pnpm lint` exit 0 · `pnpm check:cli-command-ids` exit 0 · `pnpm check:adr-symbol-anchors` exit 0. - Exit-code file: `gates-ran-b5edf8ea.txt` (seat project folder). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czA9H21XfNGbug1dac)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b9cc71 commit 9038187

16 files changed

Lines changed: 484 additions & 48 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/plugin-approvals': patch
5+
'@objectstack/service-automation': patch
6+
---
7+
8+
fix(audit): name the impersonating administrator on identity writes and on an approval decided while impersonating
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
Two writes an administrator makes while impersonating a user still read as the user's own in the audit ledger. Both now carry the same attribution-only `impersonatedBy` a record write from that session already carries, recorded as `sys_audit_log.metadata.impersonated_by`. `user_id`, `actor` and every permission stay the impersonated user's.
14+
15+
- **Identity-table writes (`@objectstack/plugin-auth`).** A write better-auth makes on the user's behalf (renaming the profile, changing an email, a membership change, an admin route such as set-user-manager) runs as the system and is credited to the user through `attributedUserId`. On an impersonation session it now also names the administrator, read from the session row's `impersonatedBy`, never from the request body. `authSystemWriteContext()` builds it, and the adapter's `withSystemContext` writes now use that same builder. `resolveAttributedActor()` (new) answers the user and the administrator together; `runAttributedToUser()` also accepts the admitted actor object, whose new optional `impersonatedBy` the platform-admin gate fills from the session.
16+
- **The approval decider's segment (`@objectstack/spec`, `@objectstack/plugin-approvals`, `@objectstack/service-automation`).** `ResumeSignal.impersonatedBy` (new, optional) names the administrator when the decider decided from an impersonation session. It is honoured only beside `resumedBy`, on a signal carrying `RESUME_AUTHORITY_SERVICE`, so an HTTP body cannot set it. The approvals decision door fills it from the decision request's context, and a stranded decision's journal keeps it for the replay. The nodes after the decision now carry exactly the decision's administrator, and none when the decider acted in their own name. Before this, a trigger user deciding their own run kept the stamp of the write that started it, and a decider deciding while impersonated carried none.
17+
18+
No schema column and no migration.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ export interface ApprovalResumeSurface {
206206
* decided (the SLA sweep), and then the run keeps the identity it had.
207207
*/
208208
resumedBy?: string;
209+
/**
210+
* The administrator impersonating {@link resumedBy} when the decision came
211+
* from an impersonation session (`ResumeSignal.impersonatedBy`), read off
212+
* the decision request's context ({@link impersonatorOf}); the engine
213+
* stamps it on the decider's segment. Absent when the decider acted in
214+
* their own name, and never sent without `resumedBy`.
215+
*/
216+
impersonatedBy?: string;
209217
/**
210218
* #3801: the engine refuses a resume of an `approval` suspension unless
211219
* the signal carries this marker — the proof that the resume is the tail
@@ -960,6 +968,13 @@ export interface StrandedContinuationSignal {
960968
* original carried none.
961969
*/
962970
resumedBy?: string;
971+
/**
972+
* The administrator the original resume named as impersonating
973+
* {@link resumedBy} (`ResumeSignal.impersonatedBy`), so a replay stamps the
974+
* decider's segment exactly as the decision did. Absent when the original
975+
* carried none.
976+
*/
977+
impersonatedBy?: string;
963978
}
964979

965980
/**
@@ -1110,6 +1125,18 @@ function actingUserId(context: ExecutionContext | undefined): string | null {
11101125
return typeof userId === 'string' && userId ? userId : null;
11111126
}
11121127

1128+
/**
1129+
* The administrator a request's context names as impersonating its user — the
1130+
* session-resolved, attribution-only `ExecutionContext.impersonatedBy` — as a
1131+
* non-blank string, or `undefined`. Never read off a caller's input.
1132+
*/
1133+
function impersonatorOf(context: ExecutionContext | undefined): string | undefined {
1134+
const by = context?.impersonatedBy;
1135+
if (typeof by !== 'string') return undefined;
1136+
const trimmed = by.trim();
1137+
return trimmed ? trimmed : undefined;
1138+
}
1139+
11131140
/**
11141141
* The PERSON an action records (ADR-0118 D1): a `sys_user` id, or `null` —
11151142
* never the slot the action took (that is `acted_as`), never an address the
@@ -5268,7 +5295,7 @@ export class ApprovalService implements IApprovalService {
52685295
*/
52695296
private async serviceResume(
52705297
runId: string,
5271-
signal: { output?: Record<string, unknown>; branchLabel?: string; resumedBy?: string },
5298+
signal: { output?: Record<string, unknown>; branchLabel?: string; resumedBy?: string; impersonatedBy?: string },
52725299
): Promise<{ runId: string; repairable: boolean; error: string } | undefined> {
52735300
const result = await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
52745301
const reported = result as
@@ -5485,7 +5512,7 @@ export class ApprovalService implements IApprovalService {
54855512
runId: string,
54865513
requestId: string,
54875514
what: string,
5488-
signal: { output?: Record<string, unknown>; branchLabel?: string; resumedBy?: string },
5515+
signal: { output?: Record<string, unknown>; branchLabel?: string; resumedBy?: string; impersonatedBy?: string },
54895516
decision: string,
54905517
): Promise<{ resumed: boolean; resumeError?: string; resumeFailure?: ResumeFailureReport }> {
54915518
const missing = this.missingRunCapability(runId, requestId, what, 'resume');
@@ -5546,6 +5573,7 @@ export class ApprovalService implements IApprovalService {
55465573
await this.journalStrandedContinuation(requestId, {
55475574
branchLabel: signal.branchLabel, output: signal.output, decision, what,
55485575
...(signal.resumedBy ? { resumedBy: signal.resumedBy } : {}),
5576+
...(signal.resumedBy && signal.impersonatedBy ? { impersonatedBy: signal.impersonatedBy } : {}),
55495577
});
55505578
}
55515579
throw strandedDecisionFailure(
@@ -5618,6 +5646,11 @@ export class ApprovalService implements IApprovalService {
56185646
// vouches for nobody, so its auto-decision names no decider and the run
56195647
// keeps the identity it had.
56205648
const decider = actingUserId(context);
5649+
// The administrator at the keyboard when this decision came from an
5650+
// impersonation session, off the decision request's own context (the
5651+
// session), never off the input: the decider's segment names them, as
5652+
// the decision's own audit row does. Only beside a decider.
5653+
const impersonatedBy = decider ? impersonatorOf(context) : undefined;
56215654
const outcome = await this.resumeRecordedOutcome(
56225655
result.runId, requestId, `the ${result.decision} decision`,
56235656
{
@@ -5629,6 +5662,7 @@ export class ApprovalService implements IApprovalService {
56295662
// whitelist already rejects them; this is defense in depth).
56305663
output: { ...(result.outputs ?? {}), decision: result.decision, requestId },
56315664
...(decider ? { resumedBy: decider } : {}),
5665+
...(impersonatedBy ? { impersonatedBy } : {}),
56325666
},
56335667
result.decision,
56345668
);
@@ -8025,6 +8059,11 @@ export class ApprovalService implements IApprovalService {
80258059
// The journalled decider (ruling A): the replayed nodes after the
80268060
// decision run as the person who decided, not as the run's trigger.
80278061
...(typeof signal.resumedBy === 'string' && signal.resumedBy ? { resumedBy: signal.resumedBy } : {}),
8062+
// The journalled impersonator beside that decider: the replayed
8063+
// segment is stamped exactly as the decision's own resume would have.
8064+
...(typeof signal.resumedBy === 'string' && signal.resumedBy
8065+
&& typeof signal.impersonatedBy === 'string' && signal.impersonatedBy.trim()
8066+
? { impersonatedBy: signal.impersonatedBy.trim() } : {}),
80288067
},
80298068
signal.decision,
80308069
);

‎packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,7 @@ describe('ruling A — a decision resumes the run as the person who decided', ()
948948

949949
let data: ReturnType<typeof makeFakeEngine>;
950950
let service: ApprovalService;
951-
let seen: Record<string, { userId?: string; permissions?: string[]; isSystem?: boolean }>;
951+
let seen: Record<string, { userId?: string; permissions?: string[]; isSystem?: boolean; impersonatedBy?: string }>;
952952
let rejectThrowsOnce: boolean;
953953

954954
function boot() {
@@ -963,7 +963,12 @@ describe('ruling A — a decision resumes the run as the person who decided', ()
963963
throw new Error(DOWNSTREAM_FAILURE);
964964
}
965965
const ctx = resolveRunDataContext(context);
966-
seen[node.id] = { userId: ctx?.userId, permissions: ctx?.permissions, ...(ctx?.isSystem ? { isSystem: true } : {}) };
966+
seen[node.id] = {
967+
userId: ctx?.userId, permissions: ctx?.permissions,
968+
...(ctx?.isSystem ? { isSystem: true } : {}),
969+
// Present only on a write the audit writer would stamp `impersonated_by`.
970+
...((ctx as any)?.impersonatedBy !== undefined ? { impersonatedBy: (ctx as any).impersonatedBy } : {}),
971+
};
967972
return { success: true };
968973
},
969974
} as never);
@@ -1034,6 +1039,45 @@ describe('ruling A — a decision resumes the run as the person who decided', ()
10341039
expect(out.source).toBe('journal');
10351040
expect(seen.after_reject, 'the replay fell back to the trigger user').toEqual({ userId: 'u1', permissions: ['deal_manager'] });
10361041
});
1042+
1043+
// #30851: an administrator impersonating the decider made THIS decision, so
1044+
// the decider's segment names them; read off the decision request's context
1045+
// (the session), never off the input.
1046+
const IMPERSONATED_DECIDER_CTX = { ...DECIDER_CTX, impersonatedBy: 'adm' } as any;
1047+
1048+
it.each(['approve', 'reject'] as const)('%s while impersonated: the nodes after the decision carry the administrator beside the decider', async (decision) => {
1049+
const automation = boot();
1050+
const req = await parked(automation);
1051+
const out = await service.decide(req.id, { decision, actorId: 'u1', comment: decision }, IMPERSONATED_DECIDER_CTX);
1052+
expect(out.resumed, JSON.stringify(out)).toBe(true);
1053+
expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: ['deal_manager'], impersonatedBy: 'adm' });
1054+
});
1055+
1056+
it('CONTROL: an administrator named in the decision INPUT is not the session, and stamps nothing', async () => {
1057+
const automation = boot();
1058+
const req = await parked(automation);
1059+
const out = await service.decide(
1060+
req.id, { decision: 'approve', actorId: 'u1', comment: 'ok', impersonatedBy: 'forged' } as any, DECIDER_CTX,
1061+
);
1062+
expect(out.resumed, JSON.stringify(out)).toBe(true);
1063+
expect(seen.after_approve).toEqual({ userId: 'u1', permissions: ['deal_manager'] });
1064+
});
1065+
1066+
it('a stranded impersonated decision is replayed with the same administrator beside the decider', async () => {
1067+
const automation = boot();
1068+
const req = await parked(automation);
1069+
rejectThrowsOnce = true;
1070+
const err = await service.decide(req.id, { decision: 'reject', actorId: 'u1', comment: 'no' }, IMPERSONATED_DECIDER_CTX)
1071+
.then(() => null, (e: Error) => e);
1072+
expect(err?.message).toMatch(/^RESUME_FAILED/);
1073+
const journal = JSON.parse((await rowOf(req.id)).node_config_json).__strandedContinuation;
1074+
expect(journal).toMatchObject({ decision: 'reject', resumedBy: 'u1', impersonatedBy: 'adm' });
1075+
1076+
expect((await automation.restoreConsumedSuspension(req.flow_run_id)).restored).toBe(true);
1077+
const out = await service.continueRestoredRun(req.id, { requestedBy: 'ops', reason: 'fixed the record' });
1078+
expect(out.resumed, JSON.stringify(out)).toBe(true);
1079+
expect(seen.after_reject).toEqual({ userId: 'u1', permissions: ['deal_manager'], impersonatedBy: 'adm' });
1080+
});
10371081
});
10381082

10391083
/**

‎packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,58 @@ describe.concurrent('ADR-0068 D2 — /admin/impersonate-user admits the ObjectSt
284284
});
285285
});
286286

287+
// ───────────────────────────────────────────────────────────────────────────
288+
// #30851 — an identity write made from the impersonation session names the
289+
// administrator beside the impersonated user it is credited to. The engine's
290+
// `buildProvenance` copies `impersonatedBy` onto the hook context, where the
291+
// audit writer records it as `sys_audit_log.metadata.impersonated_by`.
292+
describe.concurrent('an identity write made while impersonating names the administrator', () => {
293+
/** Every `sys_user` update's context, as the better-auth adapter hands it to the engine. */
294+
const recordUserWrites = (engine: any) => {
295+
const writes: Array<{ data: any; context: any }> = [];
296+
const update = engine.update.bind(engine);
297+
engine.update = async (name: string, data: any, options?: any) => {
298+
if (name === 'sys_user') writes.push({ data, context: options?.context });
299+
return update(name, data, options);
300+
};
301+
return writes;
302+
};
303+
const updateUser = (manager: AuthManager, bearer: string, body: Record<string, unknown>) =>
304+
manager.handleRequest(
305+
new Request(`${BASE}/update-user`, {
306+
method: 'POST',
307+
headers: { 'Content-Type': 'application/json', authorization: `Bearer ${bearer}` },
308+
body: JSON.stringify(body),
309+
}),
310+
);
311+
312+
it('a rename from the impersonation session carries the administrator, read off the session and not the body', async () => {
313+
const { engine, manager, adminBearer, adminId, targetId } = await arrange();
314+
const writes = recordUserWrites(engine);
315+
const started = await impersonate(manager, adminBearer, targetId);
316+
expect(started.status).toBe(200);
317+
318+
const renamed = await updateUser(manager, bearerFrom(started), { name: 'Renamed by admin', impersonatedBy: 'usr_forged' });
319+
expect(renamed.status).toBe(200);
320+
const write = writes.find((w) => w.data?.name === 'Renamed by admin');
321+
expect(write, JSON.stringify(writes)).toBeDefined();
322+
expect(write!.context).toMatchObject({ isSystem: true, attributedUserId: targetId, impersonatedBy: adminId });
323+
expect(write!.context.userId, 'the write still authorizes as the system').toBeUndefined();
324+
});
325+
326+
it("CONTROL: the administrator's own rename names no administrator", async () => {
327+
const { engine, manager, adminBearer, adminId } = await arrange();
328+
const writes = recordUserWrites(engine);
329+
330+
const renamed = await updateUser(manager, adminBearer, { name: 'Renamed self' });
331+
expect(renamed.status).toBe(200);
332+
const write = writes.find((w) => w.data?.name === 'Renamed self');
333+
expect(write, JSON.stringify(writes)).toBeDefined();
334+
expect(write!.context).toMatchObject({ isSystem: true, attributedUserId: adminId });
335+
expect('impersonatedBy' in write!.context).toBe(false);
336+
});
337+
});
338+
287339
// ───────────────────────────────────────────────────────────────────────────
288340
describe.concurrent('the other direction — a non-entitled caller is still refused', () => {
289341
it('a signed-in plain member is refused 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', async () => {

‎packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import { describe, it, expect, vi } from 'vitest';
1616
import type { IDataEngine } from '@objectstack/core';
1717
import {
1818
runWithAuthActorScope,
19+
runAttributedToUser,
1920
setAuthActorResolver,
2021
resolveAttributedUserId,
22+
resolveAttributedActor,
2123
authSystemWriteContext,
2224
} from './auth-actor-attribution';
2325
import { withSystemContext } from './objectql-adapter';
@@ -204,3 +206,68 @@ describe('withSystemContext carries attribution on WRITES only (#4586)', () => {
204206
});
205207
});
206208
});
209+
210+
// The real-pipeline pin (an impersonated `/update-user` on better-auth) lives
211+
// with the impersonation harness, in `admin-impersonate-endpoint.test.ts`.
212+
describe('an impersonation session names its administrator on identity writes (#30851)', () => {
213+
it('the context carries the administrator beside the impersonated user, attribution only', async () => {
214+
const ctx = await runWithAuthActorScope(async () => {
215+
setAuthActorResolver(async () => ({ userId: 'usr_target', impersonatedBy: 'usr_admin' }));
216+
return authSystemWriteContext();
217+
});
218+
expect(ctx).toEqual({ isSystem: true, attributedUserId: 'usr_target', impersonatedBy: 'usr_admin' });
219+
expect(ctx).not.toHaveProperty('userId');
220+
});
221+
222+
it('CONTROL: a session that is not an impersonation carries no administrator', async () => {
223+
for (const answer of ['usr_target', { userId: 'usr_target' }, { userId: 'usr_target', impersonatedBy: null }, { userId: 'usr_target', impersonatedBy: ' ' }]) {
224+
const ctx = await runWithAuthActorScope(async () => {
225+
setAuthActorResolver(async () => answer);
226+
return authSystemWriteContext();
227+
});
228+
expect(ctx, JSON.stringify(answer)).toEqual({ isSystem: true, attributedUserId: 'usr_target' });
229+
}
230+
});
231+
232+
it('CONTROL: an administrator with no user beside it attributes nothing', async () => {
233+
const actor = await runWithAuthActorScope(async () => {
234+
setAuthActorResolver(async () => ({ userId: null, impersonatedBy: 'usr_admin' }));
235+
return resolveAttributedActor();
236+
});
237+
expect(actor).toBeUndefined();
238+
});
239+
240+
it('withSystemContext stamps it on insert / update / delete, never on reads', async () => {
241+
const engine = {
242+
insert: vi.fn().mockResolvedValue({ id: '1' }),
243+
update: vi.fn().mockResolvedValue({ id: '1' }),
244+
delete: vi.fn().mockResolvedValue(undefined),
245+
find: vi.fn().mockResolvedValue([]),
246+
findOne: vi.fn().mockResolvedValue({ id: '1' }),
247+
count: vi.fn().mockResolvedValue(0),
248+
} as unknown as IDataEngine;
249+
await runWithAuthActorScope(async () => {
250+
setAuthActorResolver(async () => ({ userId: 'usr_target', impersonatedBy: 'usr_admin' }));
251+
const e = withSystemContext(engine);
252+
await e.insert('sys_user', { id: 'u1' } as any);
253+
await e.update('sys_user', { id: 'u1' } as any);
254+
await e.delete('sys_user', { where: { id: 'u1' } } as any);
255+
await e.find('sys_user', { where: {} });
256+
});
257+
const expected = { context: { attributedUserId: 'usr_target', impersonatedBy: 'usr_admin', isSystem: true } };
258+
expect(engine.insert).toHaveBeenCalledWith('sys_user', { id: 'u1' }, expected);
259+
expect(engine.update).toHaveBeenCalledWith('sys_user', { id: 'u1' }, expected);
260+
expect(engine.delete).toHaveBeenCalledWith('sys_user', expect.objectContaining(expected));
261+
expect(engine.find).toHaveBeenCalledWith('sys_user', expect.objectContaining({ context: { isSystem: true } }));
262+
});
263+
264+
it('a framework route that gated the admitted actor carries the administrator it names', async () => {
265+
const impersonated = await runAttributedToUser({ id: 'usr_target', impersonatedBy: 'usr_admin' }, () => authSystemWriteContext());
266+
expect(impersonated).toEqual({ isSystem: true, attributedUserId: 'usr_target', impersonatedBy: 'usr_admin' });
267+
const plain = await runAttributedToUser({ id: 'usr_admin' }, () => authSystemWriteContext());
268+
expect(plain).toEqual({ isSystem: true, attributedUserId: 'usr_admin' });
269+
const bare = await runAttributedToUser('usr_admin', () => authSystemWriteContext());
270+
expect(bare).toEqual({ isSystem: true, attributedUserId: 'usr_admin' });
271+
});
272+
});
273+

0 commit comments

Comments
 (0)