fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge (#9653) - #10013
Conversation
…ADR-0068 platform-admin judge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…o/* gate + move the four routes to the objectstack-gate bucket Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…robe dispatches in the delegation pin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…ype so the TEST_DEBT ratchet stays at 110 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 11 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 1ad776c9289deb531f4184a93f4ceb4a42b05b5d && git checkout 1ad776c9289deb531f4184a93f4ceb4a42b05b5d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 73cfddfa9ceed60537ae75605c529744b62f0567 35dd8573cbfe88f7da99893009ed543a8683de94 && git checkout -B drift-repro 73cfddfa9ceed60537ae75605c529744b62f0567 && git merge --no-ff 35dd8573cbfe88f7da99893009ed543a8683de94
node scripts/docs-audit/affected-docs.mjs --json 73cfddfa9ceed60537ae75605c529744b62f0567 |
Fixes #9653
The four
/admin/sso/*bridges (register,register-saml,request-domain-verification,verify-domain) now run the same inline ADR-0068 platform-admin gate their seven/admin/siblings carry — before delegating into better-auth — via the shared judge #9970 landed (platform-admin-gate.ts, no fifth predicate copy; the block-scopedgateAdminhelper is hoisted so all raw admin mounts share one spelling). Anonymous-first ordering: the identity error now precedes both the capability error and the bridges' own body validation.The premise, answered — release-relevant
The adjudicated falsifiable premise was whether better-auth's SSO plugin itself refuses a non-admin on an SSO-enabled fixture. It does not. Measured on the INSTALLED stable
@better-auth/sso1.7.1 (read from the installed dist, and pinned live by a test in this PR):/sso/registerrequires a session and nothing more when the request names noorganizationId: its org owner/admin check runs only inside theorganizationIdbranch, so an org-less registration is admitted for any authenticated user (up toprovidersLimit, default 10).auth-manager.tsnarrows/sso/register: plain members are refused. But its admit set is platform admin OR org owner/admin — and org admins are not platform admins under ADR-0068 — and it carried no test pin until this PR. So on rc-line deployments with SSO enabled, the register bridges admitted org owners/admins to a platform-operator operation, and the vendor's own gate (the thing the bridges' "so all of its gates run" comment relied on) admits any authenticated user.Per the disclosure discipline this PR carries no reproduction steps; the gate + pins here close the bridge surface, and the remaining policy question on the direct
/sso/registerendpoint (its ADR-0024 admit set still includes org owners/admins) is recorded as #10009 — the maintainer decides disclosure/backport posture for the rc line.Before / after — route × caller (Clause-② contract review)
All routes under
/api/v1/auth. "Before — SSO on" for the two register bridges reflects the vendor gate plus the ADR-0024 hook; for the two domain routes it reflects the vendor's per-provider check (OS_SSO_DOMAIN_VERIFICATIONalso on). After is capability-independent for anon/non-admin: the gate answers first.POST /admin/sso/register404 SSO_REGISTER_FAILED401 SSO_REGISTER_FAILED(vendor 401 relayed)401 UNAUTHENTICATED404 SSO_REGISTER_FAILED403 SSO_REGISTER_FAILED(ADR-0024 hook refusal relayed)403 PERMISSION_DENIED404 SSO_REGISTER_FAILED200— registration succeeds403 PERMISSION_DENIED404 SSO_REGISTER_FAILED200POST /admin/sso/register-saml404 SAML_REGISTER_FAILED401 SAML_REGISTER_FAILED401 UNAUTHENTICATED404 SAML_REGISTER_FAILED403 SAML_REGISTER_FAILED403 PERMISSION_DENIED404 SAML_REGISTER_FAILED200— registration succeeds403 PERMISSION_DENIED404 SAML_REGISTER_FAILED200POST /admin/sso/request-domain-verification400 DOMAIN_VERIFICATION_DISABLED401(vendor relayed)401 UNAUTHENTICATED400 DOMAIN_VERIFICATION_DISABLED403/404)403 PERMISSION_DENIED400 DOMAIN_VERIFICATION_DISABLED403 PERMISSION_DENIED400 DOMAIN_VERIFICATION_DISABLEDPOST /admin/sso/verify-domain404 verify_domain_failed401(vendor relayed)401 UNAUTHENTICATED404 verify_domain_failed403/404)403 PERMISSION_DENIED404 verify_domain_failed403 PERMISSION_DENIED404 verify_domain_failedThe accept set only tightens; no platform-admin flow changes (the admitted path still re-dispatches through
authManager.handleRequest, so every inner better-auth gate and hook — including the ADR-0024 before-hook — keeps running; no hook is keyed on/admin/sso/*itself, so nothing detaches).What this PR contains
auth-plugin.ts):gateAdminhoisted above the SSO mounts; all four bridges judge the session before delegating. Rejections per ADR-0112:codeANDstatus(401 UNAUTHENTICATED/403 PERMISSION_DENIED).admin-sso-bridge-gate.test.ts, 18 tests, all on an SSO-ENABLED fixture — realbetterAuthwith the realsso()plugin,domainVerification.enabled):403 PERMISSION_DENIEDat the bridge and the vendor is never consulted;/sso/register(org-less) sails past authorization into business validation (422on a reserved providerId) — network-free; if a vendor bump turns this red with a 401/403, better-auth started refusing non-admins itself and the gate becomes pure D4 defense-in-depth.capability-disabled(whose tripwire existed exactly to force this transition) and joinobjectstack-gate, where the full three-way contrast (anon 401 / member 403 / admin-not-refused) is asserted on a real booted showcase stack.@objectstack/plugin-auth) stating the new refusals plainly.Verification (at
35dd8573c, the final head)pnpm --filter @objectstack/plugin-auth test— 57 files, 1301/1301 passed;typecheckclean;check:type-check-debtre-measure "OK — 33 ledger entr(ies) re-measured … none above its recorded number" (verify-lock VERDICTcommand-exit 0, all at35dd8573c). The ratchet caught a +2 drift from this PR's first version of the new test file (two fixture variables annotated with the generic ReturnType of the betterAuth factory); fixed by annotating with the fixture's concrete type — the ledger stays at its recorded 110.admin-route-nonadmin-refusal.dogfood.test.ts— 6/6 passed on the real booted stack at1c9b713bd, resolved through dist; the builtplugin-auth/dist/index.jswas verified to carry the gate before the run (10gateAdminoccurrences). Declared narrowing: not re-run at35dd8573c— the only later diff is a type annotation inside an in-package*.test.ts, which is not among that run's inputs (not in dist, not in the dogfood package); CI re-runs it regardless.verify-domainonly → exactly its 3 refusal pins went red with the predicted signatures (anon: codeUNAUTHORIZED≠UNAUTHENTICATEDand the vendor WAS consulted; member/org-admin:401≠403), while the other three bridges' pins, the delegation legs, and all sibling-route pins stayed green (15/18). Restored byte-identically —git hash-objectmatches the pre-ablation blob (b7a295001d…). Both legs resolve the plugin from in-package source under vitest (no dist in that loop), so no rebuild was required for the mutation to be measured; the dist-resolving dogfood run above used the rebuilt, restored tree.node scripts/pm/dispatch-gates.mjs, changeset derived from merge base5ed8ee680):check:slot-lookup("ratchet holds … none new"),check:test-source-alias("OK — 72 packages … scanned"),check:type-source-resolution("OK — 76 packages … scanned"),check-affected-docs(exit 0),check:route-envelope("✓ … 1 conformant, 0 ratcheted, 0 exempt"; the pre-existingauth-plugin.tsexemption stayed closed at 3),check:nul-bytes("OK … no raw ASCII control bytes"),check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration("adds no declared-breaking changeset"),check-changeset-no-major,check-empty-changeset, speccheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docs,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract,check:where-matcher— all green by their own verdict lines; (gate list re-run in full at35dd8573c— every verdict line green.)Out of scope, filed
/sso/registerendpoint's ADR-0024 admit set (platform admin OR org owner/admin) is now wider than the bridges' D4 posture; aligning them is a policy decision.Generated by Claude Code