Skip to content

security(plugin-auth): gate direct /sso/register on platform admin only - #10390

Draft
os-warren wants to merge 2 commits into
mainfrom
claude/issue-10009-sso-register-platform-admin-only
Draft

security(plugin-auth): gate direct /sso/register on platform admin only#10390
os-warren wants to merge 2 commits into
mainfrom
claude/issue-10009-sso-register-platform-admin-only

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #10009

⚠️ This narrows an auth accept-set. Who loses access, first:

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/register and now receive 403 SSO_REGISTER_FORBIDDEN.

Principal Before After
Org owner/admin, no platform grant admitted 403 SSO_REGISTER_FORBIDDEN
Platform admin (org-less admin_full_access) admitted admitted (unchanged)
Anonymous 401 (vendor sessionMiddleware) 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-5 under 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:

PREMISE member role            = "owner"      (a real org owner)
PREMISE legacy sys_user.role   = null         (NOT riding the retired 'admin' scalar)
PREMISE permission-set grants  = []           (NOT a platform admin)
PREMISE ① DIRECT /sso/register        → 422 {"message":"This providerId is reserved …"}   ← ADMITTED
PREMISE ② BRIDGE /admin/sso/register  → 403 {"error":{"code":"PERMISSION_DENIED", …}}     ← REFUSED

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 — so 422 /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.ts is 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) reads isPlatformAdmin / positions[] / the legacy role scalar — fields customSession contributes.

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" defect auth-manager.ts's own #8102 comment says never to reintroduce) or a change to that shared contract. Worse, a raw session user carries no positions[], so isPlatformAdminUser would fall through to role === '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/register no longer asking the org question, isOrgOrPlatformAdmin lost 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. Its isOrgAdminGrade import 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 in last-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_FORBIDDEN appeared nowhere outside its own throw. A refusal-only suite would pass on a hook that refuses everyone, so both directions are asserted:

  • ① org owner → 403 and SSO_REGISTER_FORBIDDEN (ADR-0112: code AND status, never one alone)
  • ② platform admin → still admitted (reaches the vendor's 422 /reserved/)
  • ③ anonymous → still 401, the vendor's own
  • ④ one principal, both doors, one answer

The platform admin is granted the ADR-0068 way — an org-less sys_user_permission_set link to admin_full_access — and the fixture asserts sys_user.role is 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 AuthManager with the real sso() plugin, real sign-up and real session cookies. Only sys_permission_set / sys_user_permission_set are declared locally (they live in @objectstack/plugin-security) — the last-admin-guard.test.ts precedent, so a fixture adds no dependency edge.

Ablation — predicted signature stated first, both directions

AuthManager is imported relatively (./auth-manager.js) and there is no vitest alias, so vitest executes the mutated source; the dist/ staleness hazard does not apply to the mutated module, and no rebuild could change what these legs measured.

Leg git hash-object Predicted Observed
pre-ablation da7f6561… 240/240 green
A — narrowing absent (judge admits all) 7e29fb56… refusal pins RED, admission pins GREEN 13 failed: expected 422 to be 403 ×2, expected true to be false ×11. Pins ② and ③ stayed green
B — gate refuses everyone (judge always false) 35112457… admission pins RED, every refusal pin GREEN 3 failed: expected 403 to be 422, expected false to be true ×2. Every refusal pin stayed green
restore da7f6561… — byte-identical to pre-ablation green again 240/240 green, no ABLATION marker in the file

Leg 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.mjs with no paths passed, at be985a623. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

EXIT=0  pnpm check:changeset-gate-self-tests      EXIT=0  node scripts/check-adr-0087-registration.mjs
EXIT=0  pnpm check:objectui-changeset             EXIT=0  node scripts/check-changeset-no-major.mjs
EXIT=0  pnpm check:slot-lookup                    EXIT=0  node scripts/check-empty-changeset.mjs
EXIT=0  pnpm check:test-source-alias              EXIT=0  node scripts/docs-audit/check-affected-docs.mjs
EXIT=0  pnpm check:type-source-resolution

Convention-triggered by the new/edited test files:

  • ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none new
  • check-engine-double-contract: OK — 338 pinned, 133 in the DEBT ledger, 2 exempt.
  • ✓ where-matcher conformance holds: 265 matcher(s) discovered … 0 silently-wrong
  • check-type-check-coverage: OK — 64/77 workspace packages type-checked
  • check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured … none above its recorded number.

check:type-check-debt first 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 as lint.yml does — 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-auth TEST_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 typecheck clean, vitest run 61 files / 1323 tests passed.

In-repo consumer population — bounded

Every search validated against a known-present control term first.

Scope Control Direct /sso/register callers
objectstack (this repo) admin/sso/register → 9 files 0 — all hits are prose/ADR text or the bridge target /api/v1/auth/admin/sso/register, including both sys_sso_provider metadata actions
objectui (console) api/v1/auth → present 0
objectos (docs) api/v1/auth → present 0
examples/, SDK, apps/ signIn → present 0

Zero 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.ts is not on check-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/main was merged into this branch before the final commit (#10352 had not landed). The overlap is deliberate and named: both branches add a private isPlatformAdminUserId with 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 touchedSSO_REGISTER_FORBIDDEN is already registered in error-code-ledger.zod.ts, so the refusal reuses the existing catalog member and no error-code registration was needed.


Generated by Claude Code

claude added 2 commits August 20, 2026 17:12
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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via sys_permission_set (literal))
  • content/docs/automation/approvals.mdx (via admin_full_access (literal))
  • content/docs/deployment/tenancy-modes.mdx (via sys_member (literal))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol), sys_member (literal))
  • content/docs/permissions/authorization.mdx (via admin_full_access (literal), sys_permission_set (literal))
  • content/docs/permissions/delegated-administration.mdx (via sys_member (literal), sys_permission_set (literal))
  • content/docs/permissions/permission-sets.mdx (via admin_full_access (literal), sys_member (literal), sys_permission_set (literal))
  • content/docs/permissions/permissions-matrix.mdx (via admin_full_access (literal))
  • content/docs/permissions/positions.mdx (via sys_member (literal))
  • content/docs/permissions/sharing-rules.mdx (via admin_full_access (literal))
  • content/docs/ui/audience-based-interfaces.mdx (via admin_full_access (literal))

7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_member (literal))
  • content/docs/releases/v12.mdx (via sys_permission_set (literal))
  • content/docs/releases/v13.mdx (via sys_permission_set (literal))
  • content/docs/releases/v14.mdx (via admin_full_access (literal))
  • content/docs/releases/v15.mdx (via sys_permission_set (literal))
  • content/docs/releases/v16.mdx (via admin_full_access (literal), sys_member (literal))
  • content/docs/releases/v17.mdx (via admin_full_access (literal), sys_member (literal), sys_permission_set (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 d0082236d40f54afa21befc98a685b7055acc818packageMentionDocs.

Which tree this was computed on

This run read content/docs from 72715f50a11e42749abdd8f65b48602bd0207dff — the merge of head be985a62361b54aa3755746a52cffb865f8221f4 into base d0082236d40f54afa21befc98a685b7055acc818, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d0082236d40f54afa21befc98a685b7055acc818 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

⛔ 合并前置条件(PM 记录,非观察项)

本 PR 已通过 PM 内容验收(见 #10009),但保持 draft、不武装 auto-merge —— Clause-② auth 接受集收窄,按维护者 2026-08-20 授权走「opus 开发 + 分诊审核」。

我把 PR 正文里的 "Serial note" 从注记升格为放行条件。本车道有过一次教训:一条被记成「残留而非返工」的余量说明,一小时内爆掉并把仓库合并队列堵了 3.5 小时。所以这条写成条件。

条件本身

#10352#9968 的 impersonate 半边)与本 PR 都在 auth-manager.ts 的相邻区域新增 private async isPlatformAdminUserId(userId)方法体逐行一致。我核对过 #10352 的真实 diff,不是转述本 PR 的自述。

后落地的一侧会落到两种结果之一,两种都很响:

  • 文本冲突(两处改动区域相邻/重叠),或
  • git 三方合并自动接上 → 类里出现两个同名私有方法TS2393: Duplicate function implementationTypeScript Type Check 变红。

另有一处不会变红、因而必须靠这条记录兜住的:#10352 的 docblock 写着 "Deliberately NARROWER than {@link isOrgOrPlatformAdmin}",而本 PR 删除了 isOrgOrPlatformAdmin。两者都落地后那是一个悬空 {@link} —— TSDoc 引用,编译器不管,CI 不会红。

指定顺序

#10352,后 #10390

理由不是偏好:这个顺序下,两件事在同一次冲突解决里一起收掉 —— 删掉重复的 isPlatformAdminUserId(保留一份),同时把那行悬空 {@link} 改掉。反过来的顺序会让悬空链接由 #10352 一侧引入,而那一侧的作者没有理由知道它悬空。

⚠️ 合并本 PR 前请确认:如果 #10352 已落地,本分支必须先合 main人工解决为「保留一份 isPlatformAdminUserId + 修正 docblock 引用」,再跑一次绿,而不是依赖 auto-merge。


Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants