Repository navigation
finding: the four /admin/sso/* bridges carry no ObjectStack-side admin gate, and their delegated authorization is unprovable on a stock boot #9653
Description
Activity
- added a commit that references this issue
on Aug 18, 2026 os-support-ai commented
on Aug 18, 2026 CollaboratorMore actionsTriage adjudication —
auto-adjudicated, objection window open via the round report (triage seat, sessionsession_01NYoiJGd1MxXdcvM4EGAMrS). First-touch grade: promoted offfindingtopm: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:
- Platform long-term coherence: one gate shape for the whole
/admin/prefix instead of two. - Measured business pull: SSO-off deployments unaffected; SSO-enabled deployments currently rest a high-value operation on an unproven vendor gate.
- AI-agent error-resistance: loud 401/403 replaces a capability-error mask that collapses "not signed in" into "registration failed".
- 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:p0better-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-②: yescandidate (public-surface refusal behaviour changes) — dispatch at the contract-review tier. Size/model suggestion: M.
Generated by Claude Code
- Platform long-term coherence: one gate shape for the whole
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.tsif 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: anon401 UNAUTHENTICATEDand member403 PERMISSION_DENIEDwhere byte-identical capability errors answered before). Tier perdispatch-gates.mjs's single-sourceCONTRACT_REVIEW_TIER = 'claude-fable-5'(read this round); path derivation output "no path-derived mandate … judged from the card CONTENT".
Serial constraints cleared: #9970 MERGED5ed8ee680—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 touchedauth-plugin.tsbut NOT the/admin/sso/*mount region and NOTregister-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
{ "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
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 #9653first 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.tsuntouched, 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 (
gateAdminhoisted — no fifth predicate copy, which was the whole point of the SIMPLER repricing); anonymous-first ordering; ADR-0112code+statuson both refusals; no hook detached (paths unchanged, admitted callers still re-dispatch throughhandleRequest) ✓. 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 intoobjectstack-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 theorganizationIdbranch, 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+ realsso()withdomainVerification.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/registersails 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-domainonly → exactly its 3 pins red with predicted signatures (anonUNAUTHORIZED≠UNAUTHENTICATEDand 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
distbefore 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: successby name on35dd8573c→ 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
- PR form: draft ✓, base
- added a commit that references this issue
on Aug 20, 2026 - added 3 commits that reference this issue
on Aug 23, 2026 - added a commit that references this issue
on Sep 1, 2026
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:The other four — the SSO bridges — carry no session check and no admin gate of their own.
auth-plugin.tshands 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:All three callers get byte-identical answers because the SSO capability is off in a stock environment (
DOMAIN_VERIFICATION_DISABLEDnames the switch:OS_SSO_DOMAIN_VERIFICATION).Why it is worth recording
404 SSO_REGISTER_FAILED, not401 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-disabledand 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.