Repository navigation
fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a held caller (ADR-0069 D3) - #23521
Conversation
…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>
…ld-admin-impersonation
…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>
📓 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 — 15 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 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
|
…-② 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>
Contract reviewServed-tier: The face that makes this review owed is the declaration alone: Inputs:
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT ·
1. Shape
2. Premises measured, on a booted stack
3. Gates
4. Seat amendments in this act
5. Out of scope (承接者:无, kept as Acceptance notes)
6. Landing: Tier S through the ordinary queue, once the head is all green. Generated by Claude Code |
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 thatcustomSessionnever 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 ofIAuthService.resolveUserAuthGates(#23287's one per-user posture computation, PR #23440). It then refuses a held caller withjudgeHeldSession, the held refusaljudgePlatformAdminalready gives (factored out of it, byte-identical). No new gate, no second posture computation, no new error code.Doors and verdicts (security card)
POST /admin/impersonate-userapplyPlatformAdminImpersonationPOST /sso/register@better-auth/sso, authorized by the global before-hookPOST /sso/update-provider,POST /sso/delete-provider@better-auth/sso(provider owner)set-role,get-user,update-user,list-users,list-user-sessions,revoke-user-session,revoke-user-sessions(all under/admin/)adminplugin, authorized on the legacyrolescalarrole = 'admin': 200 on every one,update-userlandedThe 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 samecodeandmessage(the remediation pointer) the session gate gives on a data read and the same bodyjudgePlatformAdmingives on the raw platform-admin mounts.Posture to verdict (H1, measured)
PASSWORD_EXPIREDPASSWORD_EXPIREDPASSWORD_EXPIREDPASSWORD_RESET_REQUIREDPASSWORD_RESET_REQUIREDPASSWORD_RESET_REQUIREDMFA_REQUIRED(global floor)MFA_REQUIREDMFA_REQUIREDMFA_REQUIRED(only the session's organization requires it)MFA_REQUIREDMFA_REQUIREDSource of that verdict: the session gate holds every scope.
computeAuthGateputs the first entry ofresolveUserAuthGatesonuser.authGate("Every scope holds a session, because a session IS the password sign-in"), andevaluateAuthGaterefuses whatever posture that carrier holds, without reading its scope. The API-key seam'saccount-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
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)
adminMiddlewarehands the endpoint carried noauthGatein 15 of 15 calls (five states, three actors), and noisPlatformAdmineither: it is the rawsys_userrow.GET /auth/get-sessionon the same session did carryauthGate. So every door asks; none reads the request.SessionPostureOracle, wired inauth-manager.tstocomputeAuthGate(userId, organizationId), asked at the start of the handler withctx.context.session.session.activeOrganizationId. SSO and the role-scalar actions: the global before-hook resolves the actor (resolveActor, user id and active organization) and callsassertActorNotHeld, which asks the samecomputeAuthGate. 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.judgeHeldSession(sessionUser)(platform-admin-gate.ts) isevaluateAuthGate(sessionUser, '')plus the judge's envelope;judgePlatformAdminnow calls it. Inside better-auth it is thrown asAPIError('FORBIDDEN', record)with that record, so the vendor lane answers the gate's nested envelope, not its own flat one. The codes are the closedAuthGateCodeSchemavocabulary.403 YOU_ARE_NOT_ALLOWED_*(same status). Pinned at the impersonation door.require_mfaset,resolveUserAuthGates(admin, { organizationId })answers[MFA_REQUIRED]andresolveUserAuthGates(admin, {})answers[], and impersonation, registration and a role-scalar write are refusedMFA_REQUIRED.[](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/verifybootStack,OS_SCIM_ENABLEDfor theadminplugin,OS_SSO_ENABLED), before the fix (plugin-auth built fromf9b421716), a signed-up member carryingsys_user.role = 'admin':list-users,get-user,list-user-sessionsupdate-user(the name changed)set-role,revoke-user-sessions,revoke-user-sessionThe 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 installedadminplugin registers (held here; impersonation's own door; a raw mount that runsjudgePlatformAdminfirst; the exit; the question), so a vendor route nobody decided for turns red.Tests
Final head
14edf8922unless stated.packages/verify/src/held-session-vendor-admin-doors.pin.test.ts, 28 cases:update-user200 with the name changed;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; scalarupdate-user403 and the name unchanged; all seven role-scalar actions 403 with the code; a held non-admin at impersonation reads the posture code;[MFA_REQUIRED]with the organization and[]without; impersonation, registration and scalarupdate-userrefused;[], the data read is 200 and impersonation is 200.held-session-platform-admin.pin.test.ts:Test Files 2 passed (2) · Tests 41 passed (41).admin-impersonate-endpoint.held-caller.test.ts(4 cases: every installedadminplugin path classified exactly once, no stale entry, the held set, every mount-judged path really opens withgateAdmin).platform-admin-gate.held-session.test.tsgains 5 cases forjudgeHeldSession, including the byte-identity with the judge. Targeted plugin-auth run (9 files,auth-manager.test.tsincluded):Tests 426 passed (426).scripts/ablation-replace.mjs, anchor 1 to 0, blob4e2afe618to67beda690; plugin-auth rebuilt;ablation-dist-preflightmarker present indist/index.jsanddist/index.mjs): the booted pin went7 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 HEAD4e2afe618,git diff HEADempty, plugin-auth rebuilt, preflight--absentgreen with the whole tree clean, pins41 passed (41).@objectstack/plugin-authfull suite atbeebec8f3(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/verifyfull suite atbeebec8f3, 3 shards: 40 files, 107 + 126 + 114 = 347 passed.tsc --noEmitpluscheck:test-typecheck): plugin-auth exit 0 at14edf8922; verify exit 0 atbeebec8f3.--no-inline-configon the 6 changed.tsfiles: 0 errors, 0 warnings. The repo-wide lint run is CI's.Gates
14edf8922:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstacknames 68 families for this diff (the PM's lead listed 50 atad5a7d7c3).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-loadsansweredPREREQUISITE NOT MET(exit 3, missing dist) until a full workspace build (turbo, 76 tasks, exit 0);check:dispatcher-error-vocabularyfound theheld.bodyrecord (see Acceptance notes), fixed in14edf8922.dispatch-gates.mjs --ranreports68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.check:adr-0087-registrationreadsnot-required (no-migration-prescription)on.changeset/23335-held-admin-vendor-doors-refused.md(BREAKING, bang, clause-② narrowing);check:changeset-no-majorandcheck:empty-changesetexit 0.Acceptance notes
update-provideranddelete-provider. Measured on the boot atad8dee166(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-vocabularyfirst readAPIError('FORBIDDEN', held.body)as a member of an imported code table and reported it unresolved. The record is the judge's own object literal, whosecodecomes from the closed posture vocabulary, so it is now bound to a local name and handed over (14edf8922, no behaviour change).judgeHeldSessionfrom@objectstack/plugin-auth, via the package'sexport *ofplatform-admin-gate.ts. No new error code.GET /sso/get-providerandGET /sso/providers(sanitized provider details; measured 200). The vendor's/sso/request-domain-verificationand/sso/verify-domainwere not measured (domain verification is off in this boot); their/admin/sso/*bridges are judged./admin/oauth2/*(the oauth-provider plugin) isSERVER_ONLYin the installed 1.7.3 (read from source), so it is no HTTP door.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_orgMfaCacheas the background refresh would, the precedentauth-manager.test.tsuses.Generated by Claude Code