Skip to content

fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a held caller (ADR-0069 D3) - #23521

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23335-held-admin-impersonation
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23335-held-admin-impersonation

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23335
Clause-②: yes (narrowing)

What changes

ADR-0069 D3 gates a held session (an expired or administrator-forced password, enforced MFA not yet enrolled) out of every protected resource until it remediates. REST, the runtime dispatcher and the platform-admin raw mounts (judgePlatformAdmin, PR #23360) already refuse one. The doors better-auth serves itself never did: their session is a raw better-auth read that customSession never decorates, and no ADR-0069 transport seam sees a request under the auth namespace. This PR closes the card's remaining leg.

Each door now asks the session gate's own posture carrier for the acting user, in the organization their session operates in: AuthManager.computeAuthGate, the first entry of IAuthService.resolveUserAuthGates (#23287's one per-user posture computation, PR #23440). It then refuses a held caller with judgeHeldSession, the held refusal judgePlatformAdmin already gives (factored out of it, byte-identical). No new gate, no second posture computation, no new error code.

Doors and verdicts (security card)

door served by a held caller before (measured, every held state) after
POST /admin/impersonate-user better-auth endpoint rebuilt by applyPlatformAdminImpersonation platform admin: 200, a session minted as the target, and that session is not held, so the hold was bypassed outright 403 with the posture code, no session minted
POST /sso/register @better-auth/sso, authorized by the global before-hook platform admin: 200, the provider row landed 403 with the posture code, no row
POST /sso/update-provider, POST /sso/delete-provider @better-auth/sso (provider owner) owning platform admin: 200, issuer rewritten to another host / row deleted 403 with the posture code, provider unchanged
set-role, get-user, update-user, list-users, list-user-sessions, revoke-user-session, revoke-user-sessions (all under /admin/) better-auth admin plugin, authorized on the legacy role scalar caller carrying role = 'admin': 200 on every one, update-user landed 403 with the posture code, the write never lands

The refusal, measured on the boot: 403 {"success":false,"error":{"code":"MFA_REQUIRED","message":"Multi-factor authentication is required. Please set up an authenticator app to continue."}}, the same code and message (the remediation pointer) the session gate gives on a data read and the same body judgePlatformAdmin gives on the raw platform-admin mounts.

Posture to verdict (H1, measured)

posture held scope (ruling C′ item 1) session gate on a non-allow-listed route (data read) these doors
PASSWORD_EXPIRED credential 403 PASSWORD_EXPIRED 403 PASSWORD_EXPIRED
PASSWORD_RESET_REQUIRED credential 403 PASSWORD_RESET_REQUIRED 403 PASSWORD_RESET_REQUIRED
MFA_REQUIRED (global floor) account 403 MFA_REQUIRED 403 MFA_REQUIRED
MFA_REQUIRED (only the session's organization requires it) account 403 MFA_REQUIRED 403 MFA_REQUIRED
none, or the posture lookup failed none 200 admitted, unchanged

Source of that verdict: the session gate holds every scope. computeAuthGate puts the first entry of resolveUserAuthGates on user.authGate ("Every scope holds a session, because a session IS the password sign-in"), and evaluateAuthGate refuses whatever posture that carrier holds, without reading its scope. The API-key seam's account-only rule is not reused here: a key is not a session, and these doors are session doors.

What an attacker who holds only a held administrator's password can no longer reach

  • Mint a session as any non-admin user (impersonation), which previously bypassed the hold outright because the minted session is not held.
  • Register an identity provider, or rewrite the issuer and endpoints of one the administrator owns, or delete it: login routing for the environment.
  • Through a legacy role = 'admin' scalar: list and read users, list and revoke their sessions, update users, set roles.

Still reachable for a held caller, on purpose: sign-in, change-password, two-factor enrollment, sign-out (remediation, unchanged); /admin/stop-impersonating (the exit from an impersonation, carried by the impersonated session); /admin/has-permission (a question that refuses no one).

How each door asks (H2, H3, H5)

  • H2, where the posture comes from (measured). With a probe line in the impersonation handler (built into dist, then removed), the caller adminMiddleware hands the endpoint carried no authGate in 15 of 15 calls (five states, three actors), and no isPlatformAdmin either: it is the raw sys_user row. GET /auth/get-session on the same session did carry authGate. So every door asks; none reads the request.
  • The ask. Impersonation: an injected SessionPostureOracle, wired in auth-manager.ts to computeAuthGate(userId, organizationId), asked at the start of the handler with ctx.context.session.session.activeOrganizationId. SSO and the role-scalar actions: the global before-hook resolves the actor (resolveActor, user id and active organization) and calls assertActorNotHeld, which asks the same computeAuthGate. The role-scalar and SSO update/delete branches skip the session read when no gate feature is active (isAuthGateActive, the same caches the computation reads), as the transport seams do.
  • H3, the refusal. judgeHeldSession(sessionUser) (platform-admin-gate.ts) is evaluateAuthGate(sessionUser, '') plus the judge's envelope; judgePlatformAdmin now calls it. Inside better-auth it is thrown as APIError('FORBIDDEN', record) with that record, so the vendor lane answers the gate's nested envelope, not its own flat one. The codes are the closed AuthGateCodeSchema vocabulary.
  • Order. Identity (the vendor's 401) first, then the posture, then authorization, as the session gate and the judge apply it. A held signed-in caller who is not an administrator now reads the posture code instead of the vendor's 403 YOU_ARE_NOT_ALLOWED_* (same status). Pinned at the impersonation door.
  • H5, organization. The organization passed is the one the session operates in. Pinned: with only the organization's require_mfa set, resolveUserAuthGates(admin, { organizationId }) answers [MFA_REQUIRED] and resolveUserAuthGates(admin, {}) answers [], and impersonation, registration and a role-scalar write are refused MFA_REQUIRED.
  • Fail-open. A posture read that fails answers [] (the computation's own contract), so the door admits, exactly as the session gate does. Pinned on the boot with the posture read failing.

Measured first (H4: the role-scalar vendor actions)

Real boot (@objectstack/verify bootStack, OS_SCIM_ENABLED for the admin plugin, OS_SSO_ENABLED), before the fix (plugin-auth built from f9b421716), a signed-up member carrying sys_user.role = 'admin':

action unheld PASSWORD_EXPIRED MFA_REQUIRED PASSWORD_RESET_REQUIRED MFA by organization
list-users, get-user, list-user-sessions 200 200 200 200 200
update-user (the name changed) 200 200 200 200 200
set-role, revoke-user-sessions, revoke-user-session 200 200 200 200 200
protected data read (control) 200 403 403 403 403

The ADR-0068 platform admin (no scalar) is refused there by the vendor with 403 YOU_ARE_NOT_ALLOWED_*, held or not. So the held-caller check applies to these seven actions, as the dispatch's surface rule names. VENDOR_ROLE_SCALAR_ADMIN_PATHS (admin-impersonate-endpoint.ts) holds them, and a unit pin classifies every route the installed admin plugin registers (held here; impersonation's own door; a raw mount that runs judgePlatformAdmin first; the exit; the question), so a vendor route nobody decided for turns red.

Tests

Final head 14edf8922 unless stated.

  • New booted pin, packages/verify/src/held-session-vendor-admin-doors.pin.test.ts, 28 cases:
    • unheld controls: impersonation 200 with one session minted, registration 200 with the row landed, owner issuer rewrite 200 with the row changed, scalar update-user 200 with the name changed;
    • per held state (PASSWORD_EXPIRED, MFA_REQUIRED, PASSWORD_RESET_REQUIRED), six cases each: impersonation 403 with the code and no session minted; registration 403 and no row; owner rewrite and delete 403 and the provider unchanged; scalar update-user 403 and the name unchanged; all seven role-scalar actions 403 with the code; a held non-admin at impersonation reads the posture code;
    • organization-only MFA: the computation reads [MFA_REQUIRED] with the organization and [] without; impersonation, registration and scalar update-user refused;
    • fail-open: with the posture read failing, the computation answers [], the data read is 200 and impersonation is 200.
    • With the existing held-session-platform-admin.pin.test.ts: Test Files 2 passed (2) · Tests 41 passed (41).
  • New unit pin, admin-impersonate-endpoint.held-caller.test.ts (4 cases: every installed admin plugin path classified exactly once, no stale entry, the held set, every mount-judged path really opens with gateAdmin). platform-admin-gate.held-session.test.ts gains 5 cases for judgeHeldSession, including the byte-identity with the judge. Targeted plugin-auth run (9 files, auth-manager.test.ts included): Tests 426 passed (426).
  • Ablation, the impersonation door's check (scripts/ablation-replace.mjs, anchor 1 to 0, blob 4e2afe618 to 67beda690; plugin-auth rebuilt; ablation-dist-preflight marker present in dist/index.js and dist/index.mjs): the booted pin went 7 failed | 21 passed (28), exactly the seven impersonation cases (three held states, the three held non-admin cases, the organization case). Registration, the SSO owner doors, the role-scalar actions, the controls and fail-open stayed green. Restore: blob equals HEAD 4e2afe618, git diff HEAD empty, plugin-auth rebuilt, preflight --absent green with the whole tree clean, pins 41 passed (41).
  • @objectstack/plugin-auth full suite at beebec8f3 (the head before a refactor that only binds the refusal record to a local name), 4 shards: 36 + 35 + 35 + 35 files, 779 + 928 (10 skipped) + 520 + 597 = 2824 passed.
  • @objectstack/verify full suite at beebec8f3, 3 shards: 40 files, 107 + 126 + 114 = 347 passed.
  • Typecheck (tsc --noEmit plus check:test-typecheck): plugin-auth exit 0 at 14edf8922; verify exit 0 at beebec8f3.
  • eslint --no-inline-config on the 6 changed .ts files: 0 errors, 0 warnings. The repo-wide lint run is CI's.

Gates

  • Derived at 14edf8922: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack names 68 families for this diff (the PM's lead listed 50 at ad5a7d7c3).
  • All 68 run locally at 14edf8922, each exit code captured before any pipe: 68 of 68 exit 0. The two dist-reading families first needed state: check:dual-build-cjs-loads answered PREREQUISITE NOT MET (exit 3, missing dist) until a full workspace build (turbo, 76 tasks, exit 0); check:dispatcher-error-vocabulary found the held.body record (see Acceptance notes), fixed in 14edf8922.
  • Reconciliation: dispatch-gates.mjs --ran reports 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
  • Changeset gates: check:adr-0087-registration reads not-required (no-migration-prescription) on .changeset/23335-held-admin-vendor-doors-refused.md (BREAKING, bang, clause-② narrowing); check:changeset-no-major and check:empty-changeset exit 0.
  • Families CI owns (lint, the artifact roster, the type-check lanes) are read from this PR's check-runs, not re-run here.

Acceptance notes

  • In-place fix under the bounded exemption: SSO update-provider and delete-provider. Measured on the boot at ad8dee166 (registration already fixed there): a held (MFA_REQUIRED) platform admin who owned a provider rewrote its issuer to another host (200, landed) and deleted another (200, row gone). Same defect class as the registration door, fixed with the same mechanical branch in the same file, pinned in the same file and covered by the same gate families. The claim's file surface named the registration hook only; the seat may record the widening.
  • check:dispatcher-error-vocabulary first read APIError('FORBIDDEN', held.body) as a member of an imported code table and reported it unresolved. The record is the judge's own object literal, whose code comes from the closed posture vocabulary, so it is now bound to a local name and handed over (14edf8922, no behaviour change).
  • New export: judgeHeldSession from @objectstack/plugin-auth, via the package's export * of platform-admin-gate.ts. No new error code.
  • Not changed, and reads only: a held provider owner still gets GET /sso/get-provider and GET /sso/providers (sanitized provider details; measured 200). The vendor's /sso/request-domain-verification and /sso/verify-domain were not measured (domain verification is off in this boot); their /admin/sso/* bridges are judged.
  • /admin/oauth2/* (the oauth-provider plugin) is SERVER_ONLY in the installed 1.7.3 (read from source), so it is no HTTP door.
  • The booted pins live in packages/verify, beside fix(plugin-auth)!: the platform-admin actions refuse a session the ADR-0069 authentication policy holds #23360's, because plugin-auth cannot depend on verify. The organization-only case primes _orgMfaCache as the background refresh would, the precedent auth-manager.test.ts uses.

Generated by Claude Code

…d caller (ADR-0069 D3)

The re-authorized impersonation endpoint, the SSO-provider registration
before-hook and the admin plugin's role-scalar actions now ask the session
gate's own posture carrier (computeAuthGate, the first entry of
resolveUserAuthGates) for the acting user in the organization the session
operates in, and refuse a held caller with judgeHeldSession, the refusal
judgePlatformAdmin gives on the raw platform-admin mounts: 403, the posture's
code and message, in the ADR-0112 envelope.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…erved platform-admin doors

A booted pin per door (impersonation, SSO-provider registration, the admin
plugin's role-scalar actions) in every held state, with the unheld controls,
an organization-only MFA hold and the posture lookup's fail-open; a unit pin
that classifies every admin-plugin route for a held caller; judgeHeldSession
unit cases. The role-scalar branch skips the session read when no gate
feature is active.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
… doors refuse a held caller

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…SO provider either (ADR-0069 D3)

Measured on a real boot: a held platform admin who owned a provider rewrote
its issuer (200, landed) and deleted another (200, row gone) through
@better-auth/sso's update-provider and delete-provider. The before-hook now
refuses a held caller there with the same refusal as registration. Pinned per
held state beside the registration door.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
check:dispatcher-error-vocabulary read `held.body` as a member of an
imported code table and reported it unresolved. The record is the judge's
own object literal (judgeHeldSession), whose `code` is the posture from the
closed AuthGateCodeSchema vocabulary; bind it locally and hand that over.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 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 — 15 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 222293f60a7fff2779269b20bb4e5ce28a34c935 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6da9c2c3d6bfefb89f1267b1d0d61acee3e25be5 — the merge of head e865d3ebd2c4979683b5df0be65a7feef4b3f940 into base 222293f60a7fff2779269b20bb4e5ce28a34c935, 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 6da9c2c3d6bfefb89f1267b1d0d61acee3e25be5 && git checkout 6da9c2c3d6bfefb89f1267b1d0d61acee3e25be5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 222293f60a7fff2779269b20bb4e5ce28a34c935 e865d3ebd2c4979683b5df0be65a7feef4b3f940 && git checkout -B drift-repro 222293f60a7fff2779269b20bb4e5ce28a34c935 && git merge --no-ff e865d3ebd2c4979683b5df0be65a7feef4b3f940

node scripts/docs-audit/affected-docs.mjs --json 222293f60a7fff2779269b20bb4e5ce28a34c935

⚠️ 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 222293f60a7fff2779269b20bb4e5ce28a34c935 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…-② yes (narrowing)

The changeset ships one new public export, judgeHeldSession, so the
declaration is yes (narrowing); the seat amended the claim and the PR body
to match.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: e865d3ebd2c4979683b5df0be65a7feef4b3f940
Local-runs: none

The face that makes this review owed is the declaration alone: Clause-②: yes (narrowing), for one new export from @objectstack/plugin-auth. The PR touches no published schema (nothing under packages/spec/src/**) and no governed path, so the lane seat writes this record in-seat, read with the security lens.

Inputs:

① Derived judgments

  • Impersonation (POST /admin/impersonate-user): right. applyPlatformAdminImpersonation's handler asks the injected SessionPostureOracle, which is AuthManager.computeAuthGate: the session gate's own first entry of resolveUserAuthGates, for the session's activeOrganizationId. It refuses a held caller with judgeHeldSession's body before any session is minted.
    • The dev measured (H2) that the caller arriving from adminMiddleware carries no authGate, so asking the gate's own carrier is the one computation, not a second oracle.
    • The verdict matches the session gate's (H1, measured): every posture scope holds a session, so PASSWORD_EXPIRED, PASSWORD_RESET_REQUIRED and MFA_REQUIRED all hold. The API-key seam's account-only rule is rightly not reused, because a key is not a session.
  • SSO-provider administration (/sso/register, /sso/update-provider, /sso/delete-provider): right. They are judged in auth-manager.ts's before-hook by assertActorNotHeld.
    • update and delete are a bounded in-place widening. A held owning admin was measured rewriting an issuer to another host and deleting a provider, which is the same defect class, mechanical, in a claimed file, under the same gate families.
    • register-sso-provider.ts only re-dispatches into that hook and is unchanged.
  • The admin plugin's role-scalar actions (seven paths, VENDOR_ROLE_SCALAR_ADMIN_PATHS): right. They were measured admitting a held caller carrying role = 'admin', writes included (H4). The same before-hook check now refuses that caller. Every admin-plugin route is classified by a unit pin, so a new vendor route cannot slip in unclassified.
  • The refusal: right. judgeHeldSession is judgePlatformAdmin's held refusal, factored out byte-identical: evaluateAuthGate(user, '') pathless, 403 { success: false, error: { code, message } }. It adds no new error code. Remediation (sign-in, change-password, two-factor enrollment, sign-out) and stop-impersonating are not held.
  • Fail-open, as the oracle is. A lookup that fails answers [] and holds no one, which is pinned. An actor resolveActor cannot resolve skips the check and leaves the vendor's own session decision in charge. That is the /sso/register hook's existing shape. The dev lists the adapter-error edge as an unmeasured inference.
  • New public export judgeHeldSession. It reaches @objectstack/plugin-auth's entry through export * from './platform-admin-gate.js'. It is a pure function over a session user that returns the held refusal or null. It widens the package's public surface by one helper, which the changeset names.

② Semver level

  • The changeset is @objectstack/plugin-auth minor, BREAKING: a narrowing of which callers three vendor-served doors serve, shipped as minor under the launch-window convention. It carries the FROM → TO, what is unchanged, and the new export.
  • The ADR-0087 disposition is not-required (no-migration-prescription). That is right: no key, option, field or stored shape moves, and nothing exists for os migrate meta to rewrite. All three changeset gates exit 0 on e865d3ebd.
  • Clause-②: yes (narrowing) now reads identically in the claim (amended in place), the PR body's line 2 and the changeset (e865d3ebd). One arm each: yes for the export, (narrowing) for the doors.

③ Boundary flags

  • Open questions: none.
  • Deviations, accepted:
  • Out-of-scope notes (承接者:无), kept as Acceptance notes:
    • a held provider owner still READS GET /sso/get-provider and /sso/providers, measured;
    • the domain-verification routes are unmeasured;
    • the /sso/register hook's skip when no actor resolves is an unmeasured inference.
  • Tests, as the dev measured them:
    • the booted pins: 41 of 41 across both files, including org-only MFA and fail-open;
    • the ablation of the impersonation check: exactly the 7 impersonation cases red, restore verified;
    • the plugin-auth full suite: 2824 passed;
    • the verify suite: 347 passed.
  • Check-runs on 14edf8922 at this read: 18 success, 6 skipped, 13 in progress, 0 failures. e865d3ebd re-runs them, and the flip waits for green.

Implemented-by: claude/issue-23335-held-admin-impersonation
Reviewed-by: session_01YRp5z4kK6vgYkrdH97ztsy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · head e865d3ebd · 2026-10-14T17:08Z

1. Shape

  • Draft, base main. Line 1 Fixes #23335, line 2 Clause-②: yes (narrowing); no other closing keyword.
  • 7 files, +797 / -13. check-governed-merges --pr 23521 reads NOT governed (810 lines).
  • The changeset bumps @objectstack/plugin-auth minor, BREAKING, with disposition not-required (no-migration-prescription). It names the three doors, the FROM → TO, what stays unchanged, and the new export judgeHeldSession.

2. Premises measured, on a booted stack

  • In every held state, including org-only MFA, the session gate refused the data read 403 with the posture code. Before the fix, a held platform admin could still:

    • impersonate (a session was minted);
    • register an SSO provider (the row landed);
    • update or delete an owned provider.

    A held caller carrying role = 'admin' was admitted to all seven role-scalar actions.

  • After the fix each door refuses 403 with the posture's code. Unheld controls still succeed, and remediation still works.

  • H1 through H5 are answered in the review record.

3. Gates

  • CI check-runs: re-running on e865d3ebd. The prior head 14edf8922 read 18 success, 6 skipped, 13 in progress, 0 failures. The flip waits for green.
  • Local: --ran over 68 of 68 families, all exit 0. The plugin-auth full suite passed 2824, the verify suite 347, and the booted pins 41 of 41.
  • The impersonation check's ablation: 7 red. Restore verified.

4. Seat amendments in this act

  • Clause-② → yes (narrowing) for the export judgeHeldSession. The claim was changed by comment_edit, the PR body's line 2 by issue_patch, and the changeset line by the dev's e865d3ebd.
  • The claim's surface gains:
    • /sso/update-provider and /sso/delete-provider (a bounded in-place widening, measured);
    • the role-scalar actions (measured admitted, H4);
    • the booted pin in packages/verify.

5. Out of scope (承接者:无, kept as Acceptance notes)

  • Held owner reads of GET /sso/get-provider and GET /sso/providers (measured).
  • The domain-verification routes (unmeasured).
  • The /sso/register hook's skip when no actor resolves (an unmeasured inference).

6. Landing: Tier S through the ordinary queue, once the head is all green.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 17:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 0be162c Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23335-held-admin-impersonation branch October 14, 2026 18:09
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/l tests tooling

Projects

None yet

2 participants