Repository navigation
fix(plugin-auth): a credential-scope posture does not hold an impersonation session (#24762) - #24866
Conversation
…nation session customSession reads session.impersonatedBy and asks computeAuthGate for the session's posture with the impersonation fact; on an impersonation session only the first account-scope posture holds it (accountScopeAuthGate over AUTH_GATE_SCOPES). The other callers omit the option and keep the list's first entry. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
… a held target carries Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
… renders it Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…personation-credential-gate
…sion pin Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…personation-credential-gate
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 16 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 6f1ba01b2b923a942f20c189796680c30348c998 && git checkout 6f1ba01b2b923a942f20c189796680c30348c998
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 81120caac889335467b6ecc6f17fcab57c46e6f5 c62a583b7371dbd168d4c49a8c81ddaed0e69e71 && git checkout -B drift-repro 81120caac889335467b6ecc6f17fcab57c46e6f5 && git merge --no-ff c62a583b7371dbd168d4c49a8c81ddaed0e69e71
node scripts/docs-audit/affected-docs.mjs --json 81120caac889335467b6ecc6f17fcab57c46e6f5
|
席位核对 · ACCEPT ·
|
Fixes #24762
Clause-②: no
An administrator who impersonated a user who must change their password (the default of Create User, Set Password and Import) was stranded: the impersonation session was refused
403 PASSWORD_RESET_REQUIREDon every protected route, and the console showed the target's change-password screen. This PR executes B1 of the ruling, items 1 to 5. Item 6 (C, the console's "Stop impersonating" exit) is objectstack-ai/objectui#13208, a separate card that stays open and is related only.The ruling this executes
Ruling
6288774214(batch #376item 1, letter E, maintainer 「同意」 2026-10-20T10:23Z), quoted:Governing text:ADR-0069 D3 and its amended note (ruling C′ on #23287, 6159450584); the ADR-0069 status block;AUTH_GATE_SCOPESinpackages/spec/src/kernel/execution-context.zod.ts(read, not edited).What changed
packages/plugins/plugin-auth/src/auth-manager.tscustomSessionreadssession.impersonatedBy(the print door's reading: a non-blank string) and passes{ impersonation: true }tocomputeAuthGate.computeAuthGatetakes an optionaloptionsargument. Withimpersonation: trueit answersaccountScopeAuthGate(gates), the firstaccount-scope posture wherever it sits in the list. That is@objectstack/core's existing reader overAUTH_GATE_SCOPES, the one the API-key admission seam already uses, so no second list exists. Without the option it answersgates[0], byte for byte as before.resolveUserAuthGatesdocblock's two sentences about what the session shows are corrected with it.content/docs/permissions/authentication.mdx: themustChangePasswordpassage gains the impersonation sentence, and says that enforced MFA still holds an impersonation session..changeset/24762-impersonation-credential-posture.md:@objectstack/plugin-authpatch,Clause-②: no.How the impersonation fact reaches
computeAuthGate(the choice the ruling left open)Measured first (H3): on a real boot,
GET /auth/get-sessionwith the impersonation session's bearer answerssession.impersonatedByequal to the administrator's id, andcustomSessionreceives the samesessionobject. The pin'simpersonateTarget()asserts this on every impersonation it uses.computeAuthGate(options.impersonation, default absent). The session gate keeps ONE carrier, and the rule lives in it.customSessioncallingresolveUserAuthGatesand filtering inline). The session posture would then be derived in two places, andcomputeAuthGatewould no longer be the session gate's carrier that the doors cite.Other callers of
computeAuthGate: unchanged for every sessionapplyPlatformAdminImpersonation'spostureOf) and the vendor-served platform-admin doors (assertActorNotHeld: the admin plugin's role-scalar actions and/sso/register|update-provider|delete-provider) pass no option. Their answer isgates[0], as before, for a non-impersonation session and for an impersonation session alike.computeAuthGateunit cases (options omitted) pass unchanged, and@objectstack/verify'sheld-session-vendor-admin-doors.pin.test.ts(both callers on a real boot, every held state) passes: 3 files, 49 tests, below.Pins (ruling item 3), each ablated
packages/verify/src/held-session-impersonation-target.pin.test.ts, a real boot through the data plane's seam, each refusal asserted as403with the posture's code nested undererror(ADR-0112):PASSWORD_RESET_REQUIREDtarget's impersonation session readsGET /data/sys_user?limit=1200, and its session payload carries noauthGate. The reset is armed through the real door,/admin/set-user-passwordwith itsmustChangePassworddefault.403 PASSWORD_RESET_REQUIRED.MFA_REQUIREDtarget's impersonation session still answers403 MFA_REQUIRED.403 MFA_REQUIREDon the impersonation session.revoked.sessionsat least 1, then the same bearer answers 401).packages/plugins/plugin-auth/src/auth-manager.test.ts, the same four cases at the producer (customSession's stampeduser.authGate), plusPASSWORD_EXPIRED, the other credential-scope posture.Ablation, one leg removing the filter. The line
if (options.impersonation === true) return accountScopeAuthGate(gates) ?? undefined;was replaced with a planted no-op that keeps the declaration build clean (void ['ablation-24762-filter-removed', accountScopeAuthGate, options];), throughscripts/ablation-replace.mjs: anchor x1 to x0, blob45d22147db17to3c94be9c4c2f. The plugin-auth build answered 0.ablation-dist-preflight.mjsfound the plant indist/index.jsanddist/index.mjs, and the filter's text absent fromdist/.expected 403 to be 200; (d) red,expected 'PASSWORD_RESET_REQUIRED' to be 'MFA_REQUIRED'; the revocation control red at its first read,expected 403 to be 200(it reads the impersonation session before revoking). (b), (c), the unheld control and both armed-hold controls stayed green.ablation-replace.mjs --restore, blob after restore equal to the HEAD blob45d22147db17,git diff HEADempty,git status --porcelainempty. Rebuilt; the plant absent fromdist/, the filter back in it. Re-run: verify 8 passed, plugin-auth 4 passed.Local verification
All on
c62a583b73(this branch's head,origin/main950f949a9cmerged in), each command underscripts/pm/os-verify-lock.sh, each exit captured before any pipe:pnpm exec turbo run build --filter='@objectstack/verify^...' --concurrency=2: exit 0, 48 of 48 tasks.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: exit 0, 170 files, 3264 passed, 10 skipped.pnpm --filter @objectstack/plugin-auth typecheck: exit 0.pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2onheld-session-impersonation-target.pin.test.ts,held-session-vendor-admin-doors.pin.test.tsandheld-session-platform-admin.pin.test.ts: exit 0, 3 files, 49 passed. The rest of the verify suite is a declared narrowing, left to CI's Test Core.pnpm --filter @objectstack/verify typecheck: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths): 100 commands, each run.--ran: 100 derived, 99 run, 1 NOT MEASURED, 0 unrun. 98 answered exit 0 on the first pass.check:skill-examplesanswered exit 3 (PREREQUISITE NOT MET: no client SDK build); after building@objectstack/clientand@objectstack/client-reactit answered exit 0. NOT MEASURED:check:dual-build-cjs-loads, reason: exit 3, PREREQUISITE NOT MET, 21 packages outside this diff's build closure have nodist/. CI's Lint & Repo Gates runs it. A direct probe:require('packages/plugins/plugin-auth/dist/index.js')loads (exit 0, 155 exports).Acceptance notes
plugin-auth. Noplugin-authharness serves a protected route through a transport seam, and the ADR-0112 envelope is the seam's, so the route-level pins are in@objectstack/verify, where the real-boot held-session pins already live. The producer-level cases are inplugin-auth.packages/spec/src/contracts/auth-service.ts,IAuthService.resolveUserAuthGates, says "the session gate carries its first entry onuser.authGate". On an impersonation session it carries the firstaccount-scope entry. Suggested wording for a later spec touch: "the session gate carries its first entry onuser.authGate(on an impersonation session, its firstaccount-scope entry)". No carrier.@objectstack/core'saccountScopeAuthGatedocblock names only the API-key reading; it now also serves the impersonation session. Not edited (out of the claim's file surface). No carrier.assertActorNotHeldand the impersonation door's caller check askcomputeAuthGatewith the option omitted (the hard stop kept their answers unchanged). So an impersonation session of a credential-held target that reaches one of those platform-admin doors is refused403with the credential code, where an unheld target's impersonation session is refused by the door's own platform-admin check (also403). No request they refuse is admitted. Recorded as an open question in the report.Generated by Claude Code