Repository navigation
fix(audit): name the impersonating administrator on identity writes and on an approval decided while impersonating - #30874
Conversation
…and on an approval decider's segment plugin-auth's identity-table writes (the better-auth adapter under withSystemContext, authSystemWriteContext, and the platform-admin routes) now carry the attribution-only impersonatedBy read off the session beside attributedUserId. An approval decision's resume signal carries the decision request's impersonatedBy (ResumeSignal.impersonatedBy, honoured only with the service marker beside resumedBy), and deciderSegmentContext stamps exactly that value on the decider's segment, dropping the trigger-time stamp, the self-decider path included. Claude-Session: https://claude.ai/code/session_016WV4czA9H21XfNGbug1dac Co-authored-by: Claude <noreply@anthropic.com>
…e approval decider's segment Claude-Session: https://claude.ai/code/session_016WV4czA9H21XfNGbug1dac Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czA9H21XfNGbug1dac Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 175 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c31db44947483ea7a70de1fc3c7d8466174f3606 && git checkout c31db44947483ea7a70de1fc3c7d8466174f3606
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cf478ea3398e90e97e73f421fd7f0842a6bd04bd b5edf8ea83f244d78f1ff1e94602b50f0cf1cde3 && git checkout -B drift-repro cf478ea3398e90e97e73f421fd7f0842a6bd04bd && git merge --no-ff b5edf8ea83f244d78f1ff1e94602b50f0cf1cde3
node scripts/docs-audit/affected-docs.mjs --json cf478ea3398e90e97e73f421fd7f0842a6bd04bd
|
|
domain:server#5 · os-elon-musk · session_016WV4cyvF7SP65frVFAYLv7 Contract review for card #30851 (follow-up to #30213 ruling A, PR #30800 records 6676544432 / 6677397495) · schema face ( Contract reviewServed-tier: The diff is 16 files, +484/-48 against merge-base ① Derived judgments
② Semver levelOne changeset, four packages at ③ Boundary flags
④ Design judgment
Findings:
No must-fix. Check-runs on Implemented-by: VERDICT: PASS |
|
domain:server#5 · os-elon-musk · session_016WV4cyvF7SP65frVFAYLv7 席位核对 · ACCEPTPR #30874 · head 维护者速读(席位校正)The body's six lines stand as written, read against the diff. The last line is now met: the contract review PASSed on this head. The claim's plan of two PRs became one: both items land here, with 席位核对
|
Requested by Elon · project thread
Fixes #30851
Clause-②: yes (widening)
Metadata-protocol: none
domain:server#5 card thread · session
session_016WV4czA9H21XfNGbug1dac· branchclaude/project-thread-xv0lyn(named in the seat's re-posted claim on the card, with the widened file surface). Branched frommainat 677d542;origin/mainmerged 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.维护者速读
impersonatedBy,审计行metadata.impersonated_by写管理员;② 审批决定后,流程里决定人那一段只带「这次决定」时的代登录管理员,本人决定则不带。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」一组覆盖本人决定、代登录决定、普通决定三种情况。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_userwrite credited to the user throughattributedUserIdand nothing else, so its audit row read as the user's own. Now:resolveActor(auth-manager-actor-guards.ts) also answers the session'simpersonatedBy, read from the session thatgetSessionFromCtxresolves or from the session row on the token fallback. Never from the request body.{ userId, impersonatedBy }.AuthActorResolvermay now answer that object (a bare string still means the user alone). The new exportresolveAttributedActor()answers the user and the administrator together, andresolveAttributedUserId()is now a projection of it.authSystemWriteContext()addsimpersonatedBybesideattributedUserId(attribution only;isSystem: trueis untouched and nouserIdappears). The adapter'swithSystemContextnow builds its write context with that same function instead of a second copy of it, so the adapter, the membership reconciler andset-user-managerall carry the key.gateAdmin→runAttributedToUser) pass the admitted actor, which now carries the gated session'simpersonatedBy(PlatformAdminActor.impersonatedBy, optional, set by the route's gate and never byjudgePlatformAdmin).buildProvenancealready copiesExecutionContext.impersonatedByontoHookContext.provenance, and the audit writer already stampssys_audit_log.metadata.impersonated_byfrom it (PR feat(audit): record the administrator on writes made while impersonating a user #30800). The key is the oneauth-session-audit.tsand feat(audit): record the administrator on writes made while impersonating a user #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 besideresumedBy, on a signal that carriesRESUME_AUTHORITY_SERVICE, exactly asresumedByis. TheAutomationContext.impersonatedBydocblock now says what a decision does to it.ApprovalService's decide path,approval-service.ts) fills it from the decision request'sExecutionContext.impersonatedBy(impersonatorOf(context)), only when there is a decider. A stranded decision's journal (StrandedContinuationSignal,__strandedContinuation) keeps it besideresumedBy, andcontinueRestoredRunreplays 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) thecontext.userId === deciderearly 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'sresolveDecisionSegmentContextdocblock 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 bothrunAsarms; (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 carryimpersonatedBy: '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,nulland a blank value; an administrator with no user attributes nothing;withSystemContextstamps writes and not reads;runAttributedToUserwith the actor object.plugin-auth/src/admin-impersonate-endpoint.test.ts(the impersonation harness): on the real better-auth pipeline, the/update-userrename made from the impersonation session reaches the engine with{ isSystem: true, attributedUserId: target, impersonatedBy: admin }, and a body keyimpersonatedByis ignored. CONTROL: the administrator's own rename carries no key.Ablations (each red, each restored;
scripts/ablation-replace.mjsWRAP mode,git diff HEADempty after each)main's shape)admin-impersonate-endpoint.test.tsauthSystemWriteContextdrops theimpersonatedByspreadauth-actor-attribution.test.ts+admin-impersonate-endpoint.test.tsmain's early return)resume-decider-identity.integration.test.tsimpersonatedByrestored-approval-continuation.test.tsimpersonatedByEvery ablated module is imported from
src/by its test, so no dist rebuild sits between the mutation and the run.Acceptance notes
mainshape (user id only), and the real-pipeline pin then fails: the impersonated rename reaches the engine with noimpersonatedBy.sys_commentfork (a separate maintainer decision on [Decision] Actions taken while impersonating a user are recorded only under that user #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.auth-manager.tsandauth-plugin.ts, in different hunks.Verification
All readings were taken on head
b5edf8ea, or on8e621657for the gate sweep. The one commit after8e621657edits onlyauth-actor-attribution.test.ts, andcheck:query-options-erasureplus eslint on that file were re-run onb5edf8ea.Builds, typecheck, package tests
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-typecheckfailed inside the wrapper with "tsc failed without diagnostics" under the default heap. Run alone with--max-old-space-size=6144it is OK (52 files, 240 errors, 131 pinned signatures held). No artifact moved:ResumeSignalis a TS interface and appears in no emitted snapshot.pnpm testper package: plugin-auth 228 files, 4096 passed, 10 skipped; service-automation 264 files, 3429 passed; plugin-approvals 115 files, 1502 passed.Gates (dispatch-gates derivation, 88 families + 3 named)
node scripts/pm/dispatch-gates.mjs --ran: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN.check:query-options-erasure: the test surface went 236 → 237 from anas anyon afindcall in the new attribution pin. The cast is gone inb5edf8ea, and the gate now exits 0 at the 236 ceiling. Its "approval-service.ts erasure count fell 10 → 4" line is the same onmain(both trees hold 52as any), so it is not this diff.pnpm lintexit 0 ·pnpm check:cli-command-idsexit 0 ·pnpm check:adr-symbol-anchorsexit 0.gates-ran-b5edf8ea.txt(seat project folder).Generated by Claude Code