security(plugin-auth): gate direct /sso/register on platform admin only - #10390
security(plugin-auth): gate direct /sso/register on platform admin only#10390os-warren wants to merge 2 commits into
Conversation
The ADR-0024 before-hook on better-auth's own POST /sso/register admitted a platform admin OR an owner/admin of the caller's active org, while the four /admin/sso/* bridges have gated on the platform-admin judge since #9653. One principal therefore got two answers for the same underlying registration -- 403 at the bridge, admitted at the direct endpoint -- which made the bridge tightening honest labelling rather than a boundary. Per the 2026-08-20 maintainer ruling on #10009, ADR-0068 D4 governs: registering an identity provider is a platform-operator action. The admit set here is now platform-admin-only. - `isPlatformAdminUserId` asks the ADR-0068 D2 permission-set question on its own (org-less admin_full_access link), through `withSystemReadContext`, fail closed. It deliberately does not consult the retired `role` scalar. - `isOrgOrPlatformAdmin` lost its only production caller with this change and is removed rather than parked; the one grade ladder it delegated to (`isOrgAdminGrade`) keeps its own pins in member-role-canonical.test.ts and its other reader in last-admin-guard.ts. - The direct surface gains its first test pins, two-directional on purpose: the org owner is refused (403 + SSO_REGISTER_FORBIDDEN, code AND status) and the platform admin is still admitted. The platform admin is granted the ADR-0068 way and the fixture asserts the legacy role scalar is NOT 'admin', so the suite cannot pass by riding the retired channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…o-register-platform-admin-only
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
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 72715f50a11e42749abdd8f65b48602bd0207dff && git checkout 72715f50a11e42749abdd8f65b48602bd0207dff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d0082236d40f54afa21befc98a685b7055acc818 be985a62361b54aa3755746a52cffb865f8221f4 && git checkout -B drift-repro d0082236d40f54afa21befc98a685b7055acc818 && git merge --no-ff be985a62361b54aa3755746a52cffb865f8221f4
node scripts/docs-audit/affected-docs.mjs --json d0082236d40f54afa21befc98a685b7055acc818
|
⛔ 合并前置条件(PM 记录,非观察项)本 PR 已通过 PM 内容验收(见 #10009),但保持 draft、不武装 auto-merge —— Clause-② auth 接受集收窄,按维护者 2026-08-20 授权走「opus 开发 + 分诊审核」。 我把 PR 正文里的 "Serial note" 从注记升格为放行条件。本车道有过一次教训:一条被记成「残留而非返工」的余量说明,一小时内爆掉并把仓库合并队列堵了 3.5 小时。所以这条写成条件。 条件本身#10352(#9968 的 impersonate 半边)与本 PR 都在 后落地的一侧会落到两种结果之一,两种都很响:
另有一处不会变红、因而必须靠这条记录兜住的:#10352 的 docblock 写着 "Deliberately NARROWER than 指定顺序理由不是偏好:这个顺序下,两件事在同一次冲突解决里一起收掉 —— 删掉重复的
Generated by Claude Code |
Fixes #10009
An organization owner or admin who is not a platform admin can no longer register an SSO identity provider. They previously passed the ADR-0024 before-hook on the direct
POST /api/v1/auth/sso/registerand now receive403 SSO_REGISTER_FORBIDDEN.403 SSO_REGISTER_FORBIDDENadmin_full_access)401(vendorsessionMiddleware)401(unchanged)No other surface changes: the
/admin/sso/*bridges already refused this principal since #9653, and every in-repo product path reaches SSO registration through those bridges (measured below).⛔ Not to be auto-merged. Clause-② (accept-set narrowing on an auth surface) — developed at
claude-opus-5under the maintainer's 2026-08-20 authorization while fable is exhausted, and waiting on triage review before landing.The ruling
Maintainer ruling 2026-08-20 (issue comment 5353918178) chose option (a): tighten the direct hook to platform-admin-only, aligning it with the #9653 posture on the bridges. ADR-0068 D4 — registering an identity provider is a platform-operator action. Options (b) (split by provider scope) and (c) (document the divergence) were rejected; if multi-org IdP self-serve ever becomes a product goal, (b) is a deliberate future ruling, not something inherited from the vendor default.
Premise, re-established before changing anything
Measured on one tree, one run, on this branch before the fix — the same org-owner principal at both doors:
providerId: 'credential'is permanently reserved by@better-auth/sso, and the reserved-id refusal sits after the vendor's whole authorization prologue and before any discovery fetch — so422 /reserved/proves admission with no network. The divergence #10009 describes is real.The shared judge did not fit this call site — why, rather than a fourth copy
platform-admin-gate.tsis the shared judge, and its own header argues that N copies of an authorization predicate is the shape that drifts. But it answers the session-shaped question:isPlatformAdminUser(sessionUser)readsisPlatformAdmin/positions[]/ the legacyrolescalar — fieldscustomSessioncontributes.This call site holds no session user. It resolves its actor through
resolveActor(ctx), the shared hook-order-independent resolver used by ~5 gates, which returns{ userId, activeOrgId }— an id. Feeding the session-shaped judge would need either a second session resolution (the exact "two resolution sites" defectauth-manager.ts's own #8102 comment says never to reintroduce) or a change to that shared contract. Worse, a raw session user carries nopositions[], soisPlatformAdminUserwould fall through torole === 'admin'— the retired D2 channel — refusing real permission-set platform admins and admitting only the channel this family is closing.So this needs the id-shaped judge, which #10348 records as not yet consolidated. Rather than mint a fourth, differently-named spelling, this PR uses the exact name and body #10352 is already landing (
isPlatformAdminUserId), so the two collide loudly on one identifier and the merge resolution is "keep one". #10348's other spellings are deliberately untouched here.The wider predicate is removed, not parked
With
/sso/registerno longer asking the org question,isOrgOrPlatformAdminlost its only production caller — and the compiler said so:error TS6133: 'isOrgOrPlatformAdmin' is declared but its value is never read(noUnusedLocals, tests excluded from the package tsconfig). It is removed rather than kept dead. ItsisOrgAdminGradeimport went with it.#5942's substance is not lost coverage: the one grade ladder keeps its own direct pins in
member-role-canonical.test.ts(case, comma and array spellings) and its other reader inlast-admin-guard.ts. What is deliberately gone is the claim that this seam asks the org question — it no longer does, and the new block pins that as a refusal rather than leaving it unstated.Pins — two-directional on purpose
The hook had no pins at all before this:
SSO_REGISTER_FORBIDDENappeared nowhere outside its ownthrow. A refusal-only suite would pass on a hook that refuses everyone, so both directions are asserted:403andSSO_REGISTER_FORBIDDEN(ADR-0112: code AND status, never one alone)422 /reserved/)401, the vendor's ownThe platform admin is granted the ADR-0068 way — an org-less
sys_user_permission_setlink toadmin_full_access— and the fixture assertssys_user.roleis NOT'admin', so the suite cannot pass by riding the retired scalar.Everything is real: a real ObjectQL engine over real better-sqlite3, the real
AuthManagerwith the realsso()plugin, real sign-up and real session cookies. Onlysys_permission_set/sys_user_permission_setare declared locally (they live in@objectstack/plugin-security) — thelast-admin-guard.test.tsprecedent, so a fixture adds no dependency edge.Ablation — predicted signature stated first, both directions
AuthManageris imported relatively (./auth-manager.js) and there is no vitest alias, so vitest executes the mutated source; thedist/staleness hazard does not apply to the mutated module, and no rebuild could change what these legs measured.git hash-objectda7f6561…7e29fb56…expected 422 to be 403×2,expected true to be false×11. Pins ② and ③ stayed green35112457…expected 403 to be 422,expected false to be true×2. Every refusal pin stayed greenda7f6561…— byte-identical to pre-ablationABLATIONmarker in the fileLeg B is the direct demonstration that a one-directional suite would have been blind here: under a hook that refuses everyone, every refusal assertion stays green and only the admission pins catch it.
Verification
Gate union run after the final commit on a clean worktree,
node scripts/pm/dispatch-gates.mjswith no paths passed, atbe985a623. Exit codes captured before any pipe; verdicts quoted from each gate's own line.Convention-triggered by the new/edited test files:
✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none newcheck-engine-double-contract: OK — 338 pinned, 133 in the DEBT ledger, 2 exempt.✓ where-matcher conformance holds: 265 matcher(s) discovered … 0 silently-wrongcheck-type-check-coverage: OK — 64/77 workspace packages type-checkedcheck-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured … none above its recorded number.check:type-check-debtfirst refused with a prerequisite, not a verdict (--re-measure cannot run: 30 workspace dependenc(ies) … have no built type entry point on disk). Satisfied it exactly aslint.ymldoes —turbo run build --filter='./packages/*' --filter='./packages/*/*',70 successful, 70 total— and re-ran to the green above.No ledger entry was raised.
@objectstack/plugin-authTEST_DEBT is left at 109 as instructed; the gate notes it now measures 108 (-1) and says lowering is optional (Not an error). Flagging rather than editing.Package verification, on the merged tree:
pnpm --filter @objectstack/plugin-auth typecheckclean,vitest run61 files / 1323 tests passed.In-repo consumer population — bounded
Every search validated against a known-present control term first.
/sso/registercallersobjectstack(this repo)admin/sso/register→ 9 files/api/v1/auth/admin/sso/register, including bothsys_sso_providermetadata actionsobjectui(console)api/v1/auth→ presentobjectos(docs)api/v1/auth→ presentexamples/, SDK,apps/signIn→ presentZero in-repo callers of the direct endpoint; deployments are not visible from here. This is the half of triage's recorded confidence gap that is measurable from the repo — it does not establish that no deployed org admin uses the endpoint.
Serial note
PR #10352 (#9968) is open and touches the same file.
auth-manager.tsis not oncheck-single-claim-paths.mjs's explicit path list, so the single-writer gate does not block either side; the residual risk is a merge conflict.origin/mainwas merged into this branch before the final commit (#10352 had not landed). The overlap is deliberate and named: both branches add a privateisPlatformAdminUserIdwith the same body — keep one. #10352 additionally references{@link isOrgOrPlatformAdmin}, which this PR removes, so that docblock line needs updating on whichever side lands second.Out of scope, filed separately
See the linked findings in the report; nothing outside this hook's region was changed.
packages/spec/**was not touched —SSO_REGISTER_FORBIDDENis already registered inerror-code-ledger.zod.ts, so the refusal reuses the existing catalog member and no error-code registration was needed.Generated by Claude Code