Skip to content

fix(plugin-auth): a credential-scope posture does not hold an impersonation session (#24762) - #24866

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24762-impersonation-credential-gate
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24762-impersonation-credential-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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_REQUIRED on 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 #376 item 1, letter E, maintainer 「同意」 2026-10-20T10:23Z), quoted:

  1. The rule. A credential-scope posture (PASSWORD_RESET_REQUIRED, PASSWORD_EXPIRED) does not hold an impersonation session. An account-scope posture (MFA_REQUIRED, and any future account-standing posture) still does. Why: the impersonation session is minted from the administrator's own session, which has passed its own posture check. It is not the target's password sign-in, and the password sign-in is the only thing a credential posture holds, per the declaration.
  2. Where: once, at the producer. In plugin-auth's customSession, when session.impersonatedBy is set, filter the postures resolveUserAuthGates reports to those whose AUTH_GATE_SCOPES[code] is account, then take the first that remains. ⛔ Do not drop gates[0]: a target held by both PASSWORD_RESET_REQUIRED and MFA_REQUIRED answers MFA_REQUIRED. No seam, door or client restates the rule.

Governing text: ADR-0069 D3 and its amended note (ruling C′ on #23287, 6159450584); the ADR-0069 status block; AUTH_GATE_SCOPES in packages/spec/src/kernel/execution-context.zod.ts (read, not edited).

What changed

  • packages/plugins/plugin-auth/src/auth-manager.ts
    • customSession reads session.impersonatedBy (the print door's reading: a non-blank string) and passes { impersonation: true } to computeAuthGate.
    • computeAuthGate takes an optional options argument. With impersonation: true it answers accountScopeAuthGate(gates), the first account-scope posture wherever it sits in the list. That is @objectstack/core's existing reader over AUTH_GATE_SCOPES, the one the API-key admission seam already uses, so no second list exists. Without the option it answers gates[0], byte for byte as before.
    • The docblock premise "Every scope holds a session, because a session IS the password sign-in" is corrected to name the impersonation session (ruling item 4). The resolveUserAuthGates docblock's two sentences about what the session shows are corrected with it.
  • content/docs/permissions/authentication.mdx: the mustChangePassword passage gains the impersonation sentence, and says that enforced MFA still holds an impersonation session.
  • .changeset/24762-impersonation-credential-posture.md: @objectstack/plugin-auth patch, Clause-②: no.
  • Pins, below. No ADR, no spec edit, no new error code and no new gate (ruling item 5).

How the impersonation fact reaches computeAuthGate (the choice the ruling left open)

Measured first (H3): on a real boot, GET /auth/get-session with the impersonation session's bearer answers session.impersonatedBy equal to the administrator's id, and customSession receives the same session object. The pin's impersonateTarget() asserts this on every impersonation it uses.

  • Chosen: a parameter on computeAuthGate (options.impersonation, default absent). The session gate keeps ONE carrier, and the rule lives in it.
  • Not chosen: a filter at the call (customSession calling resolveUserAuthGates and filtering inline). The session posture would then be derived in two places, and computeAuthGate would no longer be the session gate's carrier that the doors cite.
  • Four axes: business need, the same (both serve the measured verify-a-new-user path). Long-term soundness: the parameter keeps one derivation. AI error resistance: the parameter is opt-in and named, so a caller that omits it gets the old answer, and no second copy of the filter can drift. Startup scope: both are the same size; neither adds a gate.

Other callers of computeAuthGate: unchanged for every session

  • The impersonation door's caller check (applyPlatformAdminImpersonation's postureOf) 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 is gates[0], as before, for a non-impersonation session and for an impersonation session alike.
  • Evidence: the existing computeAuthGate unit cases (options omitted) pass unchanged, and @objectstack/verify's held-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 as 403 with the posture's code nested under error (ADR-0112):

  • (a) a PASSWORD_RESET_REQUIRED target's impersonation session reads GET /data/sys_user?limit=1 200, and its session payload carries no authGate. The reset is armed through the real door, /admin/set-user-password with its mustChangePassword default.
  • (b) the target's own session still answers 403 PASSWORD_RESET_REQUIRED.
  • (c) an MFA_REQUIRED target's impersonation session still answers 403 MFA_REQUIRED.
  • (d) a target held by both answers 403 MFA_REQUIRED on the impersonation session.
  • Controls: an unheld target reads 200 on both sessions; the MFA hold and the compound hold are proved armed on the target's own session. Ruling item 7: the forced reset's revocation (on by default) still ends the impersonation session (revoked.sessions at 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 stamped user.authGate), plus PASSWORD_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];), through scripts/ablation-replace.mjs: anchor x1 to x0, blob 45d22147db17 to 3c94be9c4c2f. The plugin-auth build answered 0. ablation-dist-preflight.mjs found the plant in dist/index.js and dist/index.mjs, and the filter's text absent from dist/.

  • verify pin, mutated: 3 failed, 5 passed. (a) red, 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.
  • plugin-auth pins, mutated: 3 failed, 1 passed. Reset, expiry and both turned red; the MFA case stayed green.
  • Restore: ablation-replace.mjs --restore, blob after restore equal to the HEAD blob 45d22147db17, git diff HEAD empty, git status --porcelain empty. Rebuilt; the plant absent from dist/, the filter back in it. Re-run: verify 8 passed, plugin-auth 4 passed.
  • A first leg deleted the line outright. Its JS build landed and its readings were the same, but the declaration build refused the then-unused import and parameter (TS6133), so the leg above replaced it for a clean build record. Nothing from the first leg is relied on.

Local verification

All on c62a583b73 (this branch's head, origin/main 950f949a9c merged in), each command under scripts/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=2 on held-session-impersonation-target.pin.test.ts, held-session-vendor-admin-doors.pin.test.ts and held-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-examples answered exit 3 (PREREQUISITE NOT MET: no client SDK build); after building @objectstack/client and @objectstack/client-react it 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 no dist/. 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

  • Pin location. The claim placed the pins in plugin-auth. No plugin-auth harness 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 in plugin-auth.
  • Spec text now imprecise for impersonation sessions (not edited: the ruling owes no spec edit). packages/spec/src/contracts/auth-service.ts, IAuthService.resolveUserAuthGates, says "the session gate carries its first entry on user.authGate". On an impersonation session it carries the first account-scope entry. Suggested wording for a later spec touch: "the session gate carries its first entry on user.authGate (on an impersonation session, its first account-scope entry)". No carrier.
  • @objectstack/core's accountScopeAuthGate docblock names only the API-key reading; it now also serves the impersonation session. Not edited (out of the claim's file surface). No carrier.
  • The asking doors still read the target's full posture on an impersonation session. assertActorNotHeld and the impersonation door's caller check ask computeAuthGate with 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 refused 403 with the credential code, where an unheld target's impersonation session is refused by the door's own platform-admin check (also 403). No request they refuse is admitted. Recorded as an open question in the report.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 81120caac889335467b6ecc6f17fcab57c46e6f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6f1ba01b2b923a942f20c189796680c30348c998 — the merge of head c62a583b7371dbd168d4c49a8c81ddaed0e69e71 into base 81120caac889335467b6ecc6f17fcab57c46e6f5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 81120caac889335467b6ecc6f17fcab57c46e6f5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T12:28Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 3fbef41b4a, not against the report (os-dev-report on #24762). It executes B1 of ruling 6288774214 (letter E, the maintainer's 「同意」), items 1–5. Head under review:

c62a583b7371dbd168d4c49a8c81ddaed0e69e71
  1. Shape.

  2. Each ruling item, read in the code.

    • 1 and 2, the rule, once at the producer.
      • customSession reads session.impersonatedBy, a value the server stores on the session row, not client input, and passes { impersonation: true } to computeAuthGate.
      • On that option alone, computeAuthGate answers accountScopeAuthGate(gates): the first account-scope posture, wherever it sits.
      • That is @objectstack/core's existing reader over AUTH_GATE_SCOPES, the one the API-key seam uses. The rule has one reader, and no copy of the scope table.
      • Without the option it answers gates[0], as before. So the other two callers, the impersonate door's caller check and assertActorNotHeld, are byte-identical for every session.
      • A target held by both postures answers MFA_REQUIRED, not "drop the first".
    • 4, the text.
      • The computeAuthGate docblock premise now names the impersonation session, and so does resolveUserAuthGates's.
      • authentication.mdx's mustChangePassword passage gains the impersonation sentence and the MFA exception.
      • The plugin-auth patch changeset is added.
    • 5 and 7, the floors.
      • No spec edit, no ADR, no new error code, no new gate.
      • The forced reset's revocation, the print door and API-key admission are unchanged. The revocation is pinned as a control.
  3. Pins (item 3), each ablated.

    • packages/verify/src/held-session-impersonation-target.pin.test.ts runs on a real boot, 8 cases:
      • (a) the PASSWORD_RESET_REQUIRED target's impersonation session reads GET /data/sys_user 200;
      • (b) the target's own session answers 403 PASSWORD_RESET_REQUIRED;
      • (c) the MFA_REQUIRED target's impersonation session answers 403 MFA_REQUIRED;
      • (d) a target held by both answers MFA_REQUIRED;
      • controls, plus the revocation ending the impersonation session (401).
    • Every refusal asserts status and error.code.
    • auth-manager.test.ts adds 4 producer cases on customSession's stamped user.authGate.
    • Ablation (the filter planted as a no-op, the dist preflight proving the plant):
      • (a) and (d) turn red, and (b) and (c) stay green. This is the ruling's direction.
      • The revocation control also turned red, because it reads the impersonation session first.
      • The restore was proven by blob equality.
  4. Changeset, @objectstack/plugin-auth patch, read sentence by sentence:

    • the stranding;
    • why an impersonation session is not the user's password sign-in;
    • "What moves": the account-scope posture only, with MFA_REQUIRED still holding;
    • "Unchanged": the user's own sign-in, revocation, API keys, the print door, and the impersonation door's own check.

    Each matches 2 and the ruling.

  5. Gates.

    • At ACCEPT, every check-run on this head is success or an expected skip, among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core and Governed Surface Queue Guard.
    • Locally, 99 of 100 derived families exited 0. 1 was NOT MEASURED (check:dual-build-cjs-loads, exit 3, prerequisite); CI measures it, and a direct require of plugin-auth's dist loads.
    • plugin-auth: 3264 passed.
  6. The dev's open questions.

    • How the fact reaches computeAuthGate: A, a named opt-in option. One derivation, and an omitted option answers the old value.
    • The asking doors: A, leave them. No request that was refused is admitted. B would add an admission path through resolveActor, a shared helper, including the edge where a demoted former admin could alter an SSO provider through an impersonation session. The asking doors stay conservative.
  7. Accepted deviations.

    • The route-level pins live in packages/verify, beside the existing real-boot held-session pins. plugin-auth has no transport seam serving a protected route.
    • accountScopeAuthGate is reused instead of importing the scope table.
    • The 22-minute lock hold, flagged.
    • A stray PID file, moved.
    • Two merges of main.
    • Model-free trailers.
  8. Out-of-scope dispositions.

    • IAuthService.resolveUserAuthGates' docblock in packages/spec says the session gate carries the first entry. On an impersonation session it now carries the first account-scope entry. That is spec prose: the seat's list of stale text.
    • @objectstack/core's accountScopeAuthGate docblock names only the API-key reading: the same list.
  9. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading. C stays on console: the password-change overlay covers the impersonation banner, so an admin impersonating a held user can only sign out — "Stop impersonating" is hidden behind it objectui#13208.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 12:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 12:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit c2d4072 Oct 20, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24762-impersonation-credential-gate branch October 20, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Impersonating a user who must change their password strands the admin on that user's change-password screen

2 participants