Skip to content

fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge (#9653) - #10013

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-9653-sso-bridge-admin-gate
Aug 19, 2026
Merged

fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge (#9653)#10013
os-warren merged 4 commits into
mainfrom
claude/issue-9653-sso-bridge-admin-gate

Conversation

@os-warren

@os-warren os-warren commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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-scoped gateAdmin helper 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/sso 1.7.1 (read from the installed dist, and pinned live by a test in this PR):

  • The vendor's /sso/register requires a session and nothing more when the request names no organizationId: its org owner/admin check runs only inside the organizationId branch, so an org-less registration is admitted for any authenticated user (up to providersLimit, default 10).
  • The vendor's domain-verification endpoints authorize per provider (registrar, or org admin for an org-scoped provider) — so the registrant of an org-less provider can drive its verification end to end.
  • On ObjectStack deployments, the pre-existing ADR-0024 before-hook in auth-manager.ts narrows /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/register endpoint (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_VERIFICATION also on). After is capability-independent for anon/non-admin: the gate answers first.

Route Caller Before — SSO off (stock, measured in #9653) Before — SSO on After (this PR)
POST /admin/sso/register anonymous 404 SSO_REGISTER_FAILED 401 SSO_REGISTER_FAILED (vendor 401 relayed) 401 UNAUTHENTICATED
member 404 SSO_REGISTER_FAILED 403 SSO_REGISTER_FAILED (ADR-0024 hook refusal relayed) 403 PERMISSION_DENIED
org owner/admin (not platform admin) 404 SSO_REGISTER_FAILED 200 — registration succeeds 403 PERMISSION_DENIED
platform admin 404 SSO_REGISTER_FAILED 200 unchanged: delegated (capability/vendor answer)
POST /admin/sso/register-saml anonymous 404 SAML_REGISTER_FAILED 401 SAML_REGISTER_FAILED 401 UNAUTHENTICATED
member 404 SAML_REGISTER_FAILED 403 SAML_REGISTER_FAILED 403 PERMISSION_DENIED
org owner/admin 404 SAML_REGISTER_FAILED 200 — registration succeeds 403 PERMISSION_DENIED
platform admin 404 SAML_REGISTER_FAILED 200 unchanged: delegated
POST /admin/sso/request-domain-verification anonymous 400 DOMAIN_VERIFICATION_DISABLED 401 (vendor relayed) 401 UNAUTHENTICATED
member 400 DOMAIN_VERIFICATION_DISABLED vendor per-provider answer (403/404) 403 PERMISSION_DENIED
org owner/admin 400 DOMAIN_VERIFICATION_DISABLED vendor per-provider answer 403 PERMISSION_DENIED
platform admin 400 DOMAIN_VERIFICATION_DISABLED vendor per-provider answer unchanged: delegated
POST /admin/sso/verify-domain anonymous 404 verify_domain_failed 401 (vendor relayed) 401 UNAUTHENTICATED
member 404 verify_domain_failed vendor per-provider answer (403/404) 403 PERMISSION_DENIED
org owner/admin 404 verify_domain_failed vendor per-provider answer 403 PERMISSION_DENIED
platform admin 404 verify_domain_failed vendor per-provider answer unchanged: delegated

The 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

  • Gate (auth-plugin.ts): gateAdmin hoisted above the SSO mounts; all four bridges judge the session before delegating. Rejections per ADR-0112: code AND status (401 UNAUTHENTICATED / 403 PERMISSION_DENIED).
  • In-package pins (admin-sso-bridge-gate.test.ts, 18 tests, all on an SSO-ENABLED fixture — real betterAuth with the real sso() plugin, domainVerification.enabled):
    • all four mounted routes × {anon 401, member 403, org-admin 403} with better-auth never consulted, plus the platform-admin leg proving delegation survives the gate;
    • the card's assertion: a member with a REAL better-auth session, capability ON, is refused 403 PERMISSION_DENIED at the bridge and the vendor is never consulted;
    • the premise pin, kept live: the same real member aimed at the vendor's own /sso/register (org-less) sails past authorization into business validation (422 on 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.
  • tests(identity): pin the unpinned admin-lifecycle clauses of identity-auth.admin-lifecycle-operations — coverage gaps from QA run #9401 #9482 dogfood bucket move: the four routes leave capability-disabled (whose tripwire existed exactly to force this transition) and join objectstack-gate, where the full three-way contrast (anon 401 / member 403 / admin-not-refused) is asserted on a real booted showcase stack.
  • Changeset (patch, @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; typecheck clean; check:type-check-debt re-measure "OK — 33 ledger entr(ies) re-measured … none above its recorded number" (verify-lock VERDICT command-exit 0, all at 35dd8573c). 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.
  • Dogfood sweep admin-route-nonadmin-refusal.dogfood.test.ts6/6 passed on the real booted stack at 1c9b713bd, resolved through dist; the built plugin-auth/dist/index.js was verified to carry the gate before the run (10 gateAdmin occurrences). Declared narrowing: not re-run at 35dd8573c — 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.
  • Ablation with differential control: gate removed from verify-domain only → exactly its 3 refusal pins went red with the predicted signatures (anon: code UNAUTHORIZEDUNAUTHENTICATED and the vendor WAS consulted; member/org-admin: 401403), while the other three bridges' pins, the delegation legs, and all sibling-route pins stayed green (15/18). Restored byte-identically — git hash-object matches 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.
  • Gate families, named + re-derived (node scripts/pm/dispatch-gates.mjs, changeset derived from merge base 5ed8ee680): 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-existing auth-plugin.ts exemption 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, spec check: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 at 35dd8573c — every verdict line green.)

Out of scope, filed


Generated by Claude Code

claude added 4 commits August 19, 2026 12:10
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 11 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 73cfddfa9ceed60537ae75605c529744b62f0567packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1ad776c9289deb531f4184a93f4ceb4a42b05b5d — the merge of head 35dd8573cbfe88f7da99893009ed543a8683de94 into base 73cfddfa9ceed60537ae75605c529744b62f0567, 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 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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@os-warren
os-warren marked this pull request as ready for review August 19, 2026 15:00
@os-warren
os-warren added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit e717ba1 Aug 19, 2026
30 checks passed
@os-warren
os-warren deleted the claude/issue-9653-sso-bridge-admin-gate branch August 19, 2026 15:21
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.

finding: the four /admin/sso/* bridges carry no ObjectStack-side admin gate, and their delegated authorization is unprovable on a stock boot

2 participants