Skip to content

fix(plugin-auth): answer /admin/has-permission from the platform-authz predicate - #12210

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-11900-has-permission-platform-authz
Aug 25, 2026
Merged

fix(plugin-auth): answer /admin/has-permission from the platform-authz predicate#12210
os-trump merged 1 commit into
mainfrom
claude/issue-11900-has-permission-platform-authz

Conversation

@os-trump

@os-trumpos-trump commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11900

What

POST /api/v1/auth/admin/has-permission evaluated the caller's permission query on the legacy user.role === 'admin' scalar that ADR-0068 D2 stopped synthesizing, so a genuine ObjectStack platform admin received a confident negative answer indistinguishable from a plain member's — a wrong ANSWER on a published authorization-answer surface, not a visible refusal. Per the maintainer ruling on the card (2026-08-25, option B by the card body's lettering), the route is now shaded with the #9652 raw-mount pattern and a platform admin's query is answered from the ADR-0068 predicate.

How — the ordering, and the one row that moves

  • A raw mount registered ahead of the better-auth catch-all resolves the session and asks the shared ADR-0068 predicate (isPlatformAdminUser, platform-admin-gate.ts). It refuses no one — this is a query surface, and the plain member's own negative answer is the correct answer, not a 403.
  • A platform admin sending a body the vendor's handler would evaluate is answered by evaluating that query against the vendor's own admin access-control statements with only the identity signal replaced (admin-has-permission-endpoint.ts). An ungranted action or an unknown resource still answers false, exactly as it would to a legacy-scalar admin — unconditional true would be a new wrong answer pointing the other way.
  • Every other caller and every body the vendor refuses to evaluate is delegated through AuthManager.handleRequest (the fix(auth): authorize before the break-glass guard on /admin/remove-user #12029 gate-then-delegate seam), so the plain member's negative answer, the enveloped anonymous refusal, and the vendor's validation answers all stand byte-for-byte, in the vendor's own validation order. Note the deliberate inversion of fix(auth): authorize before the break-glass guard on /admin/remove-user #12029: there, delegation after the gate preserves the vendor's answer on purpose; here the vendor's answer to the admitted caller WAS the defect, so the admitted branch answers and everything else delegates.
  • Ledger: the route remains a BETTER_AUTH_MOUNTED_SURFACE row; check:auth-mount-ledger accounts for this mount as shadowing a vendor-declared path (its own verdict line below) — the fix(auth): authorize before the break-glass guard on /admin/remove-user #12029 worked reading; no new ledger row.

Pre-steps carried by the ruling (measured before building)

  1. Consumer grep, including better-auth's own client SDK internals. No ObjectStack consumer (no type: 'api' action, console call, SDK row, or doc). In the installed vendor dist (better-auth 1.7.1) the only wire references to this route are the server route definition and its typings; the client plugin's checkRolePermission evaluates locally and never calls the route, and no vendor flow (client or server) calls it internally. No fork with vendor-native semantics exists.
  2. Collision check against [Epic] Migrate SCIM from @better-auth/scim 1.7.0-rc.1 to stable 1.7.x — seven-model provision, sys_scim_provider disposition, token reissue coordination, net-new Setup surface #11632's SCIM migration, on the merged ref. No SCIM module references this route; [Epic] Migrate SCIM from @better-auth/scim 1.7.0-rc.1 to stable 1.7.x — seven-model provision, sys_scim_provider disposition, token reissue coordination, net-new Setup surface #11632 has no in-flight branch. No collision.
  3. What check:auth-mount-ledger owes a shadowing mount. A mount whose exact METHOD path is vendor-declared is credited as a shadow — accounted for, not a new row (verified against the gate's own accounting rules and its verdict).

Verification (every run at 9747deb47, the head of this branch)

  • packages/plugins/plugin-auth: full vitest run79 files, 1619 tests passed; typecheck (both tsc programs) green after building the package and its dependency closure.
  • New admin-has-permission-endpoint.test.ts drives the REAL mount chain (Hono + registerAuthRoutes in front of a real AuthManager on the installed better-auth). The subject is granted platform admin the ADR-0068 way and the fixture asserts it does NOT carry the legacy scalar (a scalared fixture is answered affirmatively by the unshaded vendor and measures nothing). It pins both contrasts: the caller contrast (the admin's affirmative answer against the plain member's unchanged negative on the same query) and the query contrast (an ungranted action and an unknown resource still answer the admin false), plus the delegated remainder (anonymous refusal enveloped and unchanged; the vendor's own validation refusals, in the vendor's order, asserted for the admin caller).
  • Dogfood reclassification (part of the deliverable):admin-platform-admin-standing.dogfood.test.ts moves the route from NOT_AN_AUTHORIZATION_ANSWER to ADMITTED, with a new pin (C-11900) asserting the real answer in both directions — ran green against the live showcase boot (7 tests). The sibling admin-route-nonadmin-refusal.dogfood.test.ts is untouched and green (7 tests): the non-admin side holds, including its universal no-2xx invariant and the route's classified negative-answer exception.
  • Ablation (failing assertions predicted in writing first, then run): with the mount's admin branch made dead, exactly the predicted assertion failed — the granted-query answer, receiving the card's measured wrong negative — 1 failed / 12 passed, every other leg green (they pin vendor-native behavior). No rebuild was needed on either leg: the test imports the mutated file by relative src path in the same package, with no dist/exports resolution in between. The mutation and the restore were each confirmed on disk (injected-marker count 0→1→0; restore via git checkout HEAD -- {path}, then disk == index == HEAD verified).
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 9747deb47 — all green, quoting the gates' own verdict lines:
    • check:auth-mount-ledger: "OK -- 19 ObjectStack auth mount(s), all accounted for (13 by a reviewed ledger row, 6 shadowing a vendor-declared path, 0 pending a disposition)."
    • check:type-check-debt (--re-measure, after turbo run build --filter='./packages/*' --filter='./packages/*/*'): "32 ledger entr(ies) re-measured in 234.4s, 1843 raw tsc error(s) total, none above its recorded number."
    • check-nul-bytes: "OK (scanned 6752 text file(s) -- 6752 tracked, 0 untracked-not-ignored; skipped 6 binary; no raw ASCII control bytes)."
    • check-changeset-no-major: "This diff introduces no major bump."
    • check-engine-double-contract: "OK — 408 pinned, 133 in the DEBT ledger, 2 exempt." · check:where-matcher: "299 matcher(s) discovered … none new." · check:test-source-alias, check:type-source-resolution, check:route-envelope, check:slot-lookup, check:published-files, check:plugin-teardown-shape, check:cross-package-test-inputs, check:query-options-erasure, check:type-check-coverage, check:changeset-gate-self-tests, check:objectui-changeset, ADR-0087 registration, empty-changeset, docs-audit affected/drift, release-rehearsal self-test, and the spec liveness family (check:empty-state / check:liveness / check:strictness-ledger / check:variant-docs): all exit 0.
    • Repo-wide pnpm lint (eslint . --no-inline-config): exit 0 — full run, no narrowing.

Changeset

@objectstack/plugin-authpatch — a bug fix to a served answer on an existing route: no new API surface, no behavior change for anonymous callers or plain members (byte-identical via delegation), and no measured consumer of the old admin-side answer exists. major is refused in the launch window and nothing here breaks a contract; minor would overstate an answer correction as new capability.

Review gate

Clause-② applies (authorization answer surface): needs:contract-review is carried on the card and hung on this PR; it stays draft for the contract-review chain. Deliberately untouched: the #9969 posture for the consumer-less refusal family and the #9968 ruling — both stand exactly as ruled.


Generated by Claude Code

…-authz predicate
Shade the vendor's admin permission-query route with an ObjectStack raw
mount: a platform admin's query is evaluated against the vendor's own
admin access-control statements with only the identity signal replaced
(ADR-0068 predicate instead of the retired legacy role scalar), so the
admin now gets the answer real execution gives. Every other caller and
every body the vendor refuses to evaluate is delegated through
handleRequest, so the plain member's own negative answer, the enveloped
anonymous refusal, and the vendor's validation bytes all stand
unchanged. The mount shadows the vendor-declared ledger row; the
standing dogfood sweep reclassifies the route from
NOT_AN_AUTHORIZATION_ANSWER to ADMITTED with the answer pinned in both
directions.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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 494279cb31f1d92adab959763085e19c923a8652packageMentionDocs.

Which tree this was computed on

This run read content/docs from d37b6c75e17c4912fed2129a8290672931c8dc1e — the merge of head 9747deb479d66ce946497afb283a3a396c1e85bc into base 494279cb31f1d92adab959763085e19c923a8652, 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 d37b6c75e17c4912fed2129a8290672931c8dc1e && git checkout d37b6c75e17c4912fed2129a8290672931c8dc1e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 494279cb31f1d92adab959763085e19c923a8652 9747deb479d66ce946497afb283a3a396c1e85bc && git checkout -B drift-repro 494279cb31f1d92adab959763085e19c923a8652 && git merge --no-ff 9747deb479d66ce946497afb283a3a396c1e85bc
node scripts/docs-audit/affected-docs.mjs --json 494279cb31f1d92adab959763085e19c923a8652

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

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract-review verdict: PASS (#11900)

Reviewed by the contract-review chain at CONTRACT_REVIEW_TIER. Tier fuse, machine reading this session: get_session.external_metadata.last_served_model = claude-fable-5.

Reviewed: the full diff, the PR body's evidence, and the ruling record on #11900.

Conformance to ruling B (maintainer 2026-08-25, batch acceptance verbatim 「其他同意」):

  • The raw /admin/has-permission mount sits ahead of the better-auth catch-all — the shade lands exactly where the ruling put it, and the answering authority is the ADR-0068 isPlatformAdminUser predicate, not better-auth's own admin plugin state.
  • Both contrasts are pinned, which is what makes the shade provable rather than asserted: the delegated surface stays byte-identical, and the admin/non-admin split flips only through the predicate.
  • The pre-steps the dispatch demanded were measured, not recalled, and the changeset is patch — no public surface widens; an existing endpoint's answer becomes correct.

The seat's own ACCEPT correctly stopped short of the gate (seat fuse read below tier); this verdict completes the chain. Clearing needs:contract-review on this PR and on #11900 in the same stroke. Landing stays with the owning seat.


Generated by Claude Code

@os-trump
os-trump marked this pull request as ready for review August 25, 2026 15:23
@os-trump
os-trump added this pull request to the merge queueAug 25, 2026
@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing completed by the contract-review chain — ready + auto-merge armed (merge queue lands it when CI is green). This supersedes the "landing stays with the owning seat" line in the PASS verdict above, on maintainer instruction (2026-08-25, live PM chat, verbatim and untranslated): 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」. Standing practice from here: a contract-review PASS on a non-governed code PR completes the landing in the same stroke.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude