Skip to content

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

Description

@os-zhuang

Observed 2026-08-18 while deriving the full /admin/ surface for #9482. Filed unassigned, finding — this records a measurement and a coverage hole, not a demonstrated hole in access control.

What was measured

Nine ObjectStack raw mounts sit under /api/v1/auth/admin/. Five carry the ADR-0068 platform-admin gate inline and behave identically and correctly:

POST /admin/unlock-user            anon 401 UNAUTHENTICATED | member 403 PERMISSION_DENIED | admin 200
POST /admin/oauth2/toggle-disabled anon 401 UNAUTHENTICATED | member 403 PERMISSION_DENIED | admin 404 RESOURCE_NOT_FOUND
(same shape: create-user, set-user-password, import-users)

The other four — the SSO bridges — carry no session check and no admin gate of their own. auth-plugin.ts hands the raw request straight to a bridge function, which re-dispatches it into better-auth "so all of its gates run" (register-sso-provider.ts). Measured, with fully valid payloads:

POST /admin/sso/register                     anon 404 SSO_REGISTER_FAILED        | member 404 (same) | admin 404 (same)
POST /admin/sso/register-saml                anon 404 SAML_REGISTER_FAILED       | member 404 (same) | admin 404 (same)
POST /admin/sso/request-domain-verification  anon 400 DOMAIN_VERIFICATION_DISABLED | member 400 (same) | admin 400 (same)
POST /admin/sso/verify-domain                anon 404 verify_domain_failed       | member 404 (same) | admin 404 (same)

All three callers get byte-identical answers because the SSO capability is off in a stock environment (DOMAIN_VERIFICATION_DISABLED names the switch: OS_SSO_DOMAIN_VERIFICATION).

Why it is worth recording

  1. The delegated gate is unproven, and unprovable here. Nothing in this repo demonstrates that a plain member cannot register an SSO provider on a deployment where SSO is enabled — the capability error masks the authorization answer on every stock boot. Whether better-auth's SSO plugin refuses a non-admin, and with what, is untested. Registering an identity provider is a high-value operation, so "we delegate and assume" deserves one real assertion on an SSO-enabled fixture.
  2. The refusal is unlabelled. An anonymous caller gets 404 SSO_REGISTER_FAILED, not 401 UNAUTHENTICATED — inconsistent with the five siblings on the same path prefix, and it collapses "not signed in" into "registration failed".

Neither is a demonstrated hole: the #9482 pin asserts, over all 31 derived /admin/ routes, that no anonymous or member call ever receives a 2xx, and that assertion passes — including these four.

What the #9482 pin does with them

They are classified capability-disabled and carry only a tripwire: member and admin must receive the same answer. The day SSO is enabled in that fixture the answers diverge, the test goes red, and whoever enabled it has to move the routes into a bucket that actually checks their gate — rather than inheriting a green that had stopped meaning anything.

Closing this properly means an SSO-enabled fixture, which is why it is a separate card.

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage adjudication — auto-adjudicated, objection window open via the round report (triage seat, session session_01NYoiJGd1MxXdcvM4EGAMrS). First-touch grade: promoted off finding to pm:queue, domain:identity, type Bug.

    Ruling: the four /admin/sso/* bridges get the same inline ADR-0068 platform-admin gate their five /admin/ siblings carry (no session ⇒ 401 UNAUTHENTICATED; non-admin ⇒ 403 PERMISSION_DENIED) before delegating into better-auth — plus the one real assertion this card asks for: on an SSO-enabled fixture, a plain member cannot register an SSO provider.

    Basis: ADR-0068 D4 [ruled] — "platform-operator actions gate on isPlatformAdmin (sole operator)". Registering an identity provider is a platform-operator action; the bridges enforce nothing themselves and their delegated gate is unprovable on a stock boot (this card's measurement). This is declared(ADR-ruled) ≠ enforced ⇒ restoration, not a new policy; and the governed-side-wins meta-rule applies (the five gated siblings are the governed shape — the ungated four align to them). The accept set does not expand: refusals only tighten and get honest labels.

    Four-prong:

    1. Platform long-term coherence: one gate shape for the whole /admin/ prefix instead of two.
    2. Measured business pull: SSO-off deployments unaffected; SSO-enabled deployments currently rest a high-value operation on an unproven vendor gate.
    3. AI-agent error-resistance: loud 401/403 replaces a capability-error mask that collapses "not signed in" into "registration failed".
    4. Startup scope discipline: no new surface; reuses the existing inline gate pattern.

    Falsifiable premise — verify first, report, do not improvise: whether better-auth's SSO plugin refuses a non-admin on an SSO-enabled fixture. If it does NOT, that is a live authorization gap on rc-line deployments with SSO enabled — do not quietly fix-and-move-on: state it loudly in the PR and the report (release-relevant; the maintainer decides disclosure/backport posture). If it does, the gate still lands as D4 defense-in-depth and the fixture assertion pins both layers. Either way the #9482 tripwire bucket for these routes moves to a real gate-checking bucket.

    Serial constraints: same package as #3002 (priority:p0 better-auth family upgrade to stable 1.7.0) — claim after #3002 lands, or declare a non-overlapping file surface; take the premise measurement on 1.7.0 stable, not rc.2. Clause-②: yes candidate (public-surface refusal behaviour changes) — dispatch at the contract-review tier. Size/model suggestion: M.


    Generated by Claude Code

  2. self-assigned this
    on Aug 19, 2026
  3. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    Claim: PM loop round 5
    Session: session_01PnJHU45vPJj5UQrxe946Bx
    Branch: claude/issue-9653-sso-bridge-admin-gate
    Worktree: objectstack-issue-9653
    Domain: domain:services
    File surface: packages/plugins/plugin-auth/src/auth-plugin.ts (the four /admin/sso/* mounts) + register-sso-provider.ts if the gate insertion point requires it + the SSO-enabled fixture test (new or extended in-package) + the #9482 dogfood bucket move (packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts). Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: claude-fable-5 — Clause-②: yes per the triage adjudication (public-surface refusal behavior changes: anon 401 UNAUTHENTICATED and member 403 PERMISSION_DENIED where byte-identical capability errors answered before). Tier per dispatch-gates.mjs's single-source CONTRACT_REVIEW_TIER = 'claude-fable-5' (read this round); path derivation output "no path-derived mandate … judged from the card CONTENT".
    Serial constraints cleared: #9970 MERGED 5ed8ee680 — plugin-auth/** free, and the repricing answer from its report is SIMPLER: the shared ADR-0068 judge (platform-admin-gate.ts, judgePlatformAdmin/isPlatformAdminUser) landed unit-tested for the full posture matrix; this card is "call the judge in four handlers", not a fifth predicate copy. #9970 touched auth-plugin.ts but NOT the /admin/sso/* mount region and NOT register-sso-provider.ts — rebase from current main, derive line anchors fresh. Same-package siblings #9694/#9941 are held OUT of this batch (hard serial behind this card).


    Generated by Claude Code

  4. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator
    {
      "issue": 9653,
      "status": "done",
      "branch": "claude/issue-9653-sso-bridge-admin-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10013",
      "premise_still_valid": true,
      "summary": "Premise re-verified at branch point (post-#9970 5ed8ee680): the four /admin/sso/* bridges carried no session/admin check. All four now run the shared ADR-0068 judge (judgePlatformAdmin via a hoisted gateAdmin — no fifth predicate copy) BEFORE delegating: anon 401 UNAUTHENTICATED, non-platform-admin 403 PERMISSION_DENIED (org owners/admins included — the deliberate D4 tightening), platform admin delegated unchanged (no hook detach: paths unchanged, admitted callers still re-dispatch through handleRequest). THE ADJUDICATED FALSIFIABLE PREMISE, ANSWERED LOUDLY: better-auth 1.7.1 does NOT refuse a non-admin — /sso/register's org-admin check runs only when the body names an organizationId, so an org-less registration is admitted for ANY authenticated user (measured in the installed dist, pinned live by a network-free test); domain-verification endpoints gate per-provider (registrar/org-admin). Release-posture nuance: deployments running this repo's auth-manager carry a pre-existing ADR-0024 before-hook that refuses plain members on /sso/register, but it admits org owners/admins (not platform admins under ADR-0068) and had zero test pins until this PR — so the rc-line exposure with SSO enabled is org-admin registration of an env-wide IdP plus a vendor-only gate on any deployment not running the hook. Stated in the PR body without reproduction recipes; maintainer decides disclosure/backport. Dogfood bucket moved: the four routes left capability-disabled (bucket retired, its tripwire's purpose fulfilled) into objectstack-gate's full three-way contrast. Patch changeset states the new refusals; PR body carries the route-by-caller before/after table (Clause-②). CI on the PR: in_progress at report time (report delivered at draft-PR time per standing rule).",
      "tests": "All under scripts/pm/os-verify-lock.sh, verdicts from its VERDICT lines, at final head 35dd8573c unless noted. (1) plugin-auth suite: 57 files / 1301 passed + tsc --noEmit clean + check:type-check-debt re-measure 'OK — 33 ledger entr(ies) re-measured … none above its recorded number' — VERDICT command-exit 0. The debt ratchet first went RED (+2: TEST_DEBT 110→112) on my new test file's two ReturnType-of-betterAuth annotations; fixed to the fixture's concrete type, re-measured back to the recorded 110. (2) New admin-sso-bridge-gate.test.ts: 18/18 on an SSO-ENABLED fixture (real betterAuth + real sso() with domainVerification.enabled): 4 routes × anon-401/member-403/org-admin-403 with the vendor never consulted + platform-admin delegation legs + the card's assertion (REAL member session, capability ON → 403 PERMISSION_DENIED at the bridge, vendor never consulted) + the live premise pin (same member on the vendor's own org-less /sso/register passes authorization into business validation: 422 on a reserved providerId; anon 401). (3) Ablation, differential control: gate removed from verify-domain only → exactly its 3 refusal pins red with predicted signatures (anon code UNAUTHORIZED≠UNAUTHENTICATED and vendor WAS consulted; member/org-admin 401≠403), other 15 green; restored byte-identically, git hash-object matches pre-ablation blob b7a295001d…; both legs resolve in-package source under vitest (no dist in that loop — no rebuild owed), stated per the ablation-rebuild rule. (4) Dogfood admin-route-nonadmin-refusal: 6/6 on the real booted showcase stack at 1c9b713bd, through dist, with the gate's presence in plugin-auth/dist verified pre-run (grep: 10 gateAdmin occurrences); declared narrowing: not re-run at 35dd8573c — the only later diff is a test-file type annotation that is not among that run's inputs. (5) Gates: four named families (check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-affected-docs) + expected check:route-envelope (auth-plugin.ts exemption stayed closed at 3) + check:nul-bytes, then re-derived with node scripts/pm/dispatch-gates.mjs (no paths; merge base 5ed8ee680) which added: check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, spec check:empty-state/check:liveness/check:strictness-ledger/check:variant-docs, and convention-triggered check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:engine-double-contract, check:where-matcher — every one green by its own printed verdict line, all re-run at 35dd8573c.",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #10009: the direct /sso/register endpoint's ADR-0024 admit set (platform admin OR org owner/admin) is now wider than the bridges' D4 platform-admin-only posture — aligning the two surfaces is a policy decision (finding label, unassigned)"]
    }

    Generated by Claude Code

  5. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    ACCEPT — reviewer of record: PM session session_01PnJHU45vPJj5UQrxe946Bx, round 5. PR: #10013 (draft, CI converging — flip + enqueue on green).

    • PR form: draft ✓, base main ✓, Fixes #9653 first line ✓; 4 files, all inside the declared surface (auth-plugin.ts, new in-package pin, the tests(identity): pin the unpinned admin-lifecycle clauses of identity-auth.admin-lifecycle-operations — coverage gaps from QA run #9401 #9482 dogfood bucket move, changeset) ✓. register-sso-provider.ts untouched, as predicted.
    • Ruling compliance: all four bridges judge before delegating, via the shared judge fix(plugin-auth): serve /admin/ban-user and /admin/unban-user with the ADR-0068 platform-admin gate #9970 landed (gateAdmin hoisted — no fifth predicate copy, which was the whole point of the SIMPLER repricing); anonymous-first ordering; ADR-0112 code+status on both refusals; no hook detached (paths unchanged, admitted callers still re-dispatch through handleRequest) ✓. The tests(identity): pin the unpinned admin-lifecycle clauses of identity-auth.admin-lifecycle-operations — coverage gaps from QA run #9401 #9482 tripwire bucket moved into objectstack-gate — the transition that bucket's own header was written for ✓.
    • ⚠️ The falsifiable premise came back FALSIFIED, and was reported loudly as required. better-auth 1.7.1 does not refuse a non-admin: /sso/register's org owner/admin check runs only inside the organizationId branch, so an org-less registration is admitted for any authenticated caller; the domain-verification endpoints then authorize per provider, so whoever registered an org-less provider can drive verification end to end. This repo's pre-existing ADR-0024 before-hook narrows the direct endpoint — but its admit set is platform admin OR org owner/admin, org admins are not platform admins under ADR-0068, and it had zero test pins until this PR. Net: on rc-line deployments with SSO enabled, an org owner/admin could register an environment-wide IdP. This PR closes the bridge surface; the direct-endpoint policy gap is filed as finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009. No reproduction anywhere public, per the The datasource-admin HTTP family is reachable unauthenticated, read and write — mounts outside the enforceAuth seam #9391-family discipline. ✓
    • Pins: 18/18 on a genuinely SSO-ENABLED fixture (real betterAuth + real sso() with domainVerification.enabled) — four routes × {anon 401, member 403, org-admin 403} with the vendor never consulted, plus the platform-admin delegation legs. The premise pin is kept live and network-free: the same real member reaching the vendor's own org-less /sso/register sails into business validation (422 on a reserved providerId) — so if a vendor bump ever starts refusing non-admins, that pin goes red and tells the next reader the gate has become pure defense-in-depth. That is the right shape for a measurement that may expire.
    • Ablation with differential control: gate removed from verify-domain only → exactly its 3 pins red with predicted signatures (anon UNAUTHORIZED ≠ UNAUTHENTICATED and the vendor was consulted; member/org-admin 401 ≠ 403), other 15 green; restore hash-identical (b7a295001d…). ✓
    • Ratchet honesty: the type-check-debt gate caught a +2 drift (110→112) from the first version's ReturnType<typeof betterAuth> annotations; fixed at source rather than ratcheted up, back to the recorded 110. ✓
    • Declared narrowing accepted: the dogfood sweep (6/6 through dist, gate presence verified in dist before the run) was not re-run at the final head — the only later diff is a type annotation in an in-package test file, not among that run's inputs; CI re-runs it regardless.

    Landing plan: gate families completed: success by name on 35dd8573c → flip ready → merge queue. #10009 goes to the maintainer in this round's report — the org-admin admit set on a platform-operator operation is a policy call, not a seat call.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions