Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): answer /admin/has-permission from the platform-authz predicate - #12210
Conversation
…-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
📓 Docs Drift Check9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
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 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 |
os-trump
commented
Aug 25, 2026
Contract-review verdict: PASS (#11900)Reviewed by the contract-review chain at 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 seat's own ACCEPT correctly stopped short of the gate (seat fuse read below tier); this verdict completes the chain. Clearing Generated by Claude Code |
os-trump
commented
Aug 25, 2026
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 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11900
What
POST /api/v1/auth/admin/has-permissionevaluated the caller's permission query on the legacyuser.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
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.admin-has-permission-endpoint.ts). An ungranted action or an unknown resource still answersfalse, exactly as it would to a legacy-scalar admin — unconditionaltruewould be a new wrong answer pointing the other way.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.BETTER_AUTH_MOUNTED_SURFACErow;check:auth-mount-ledgeraccounts 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)
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'scheckRolePermissionevaluates locally and never calls the route, and no vendor flow (client or server) calls it internally. No fork with vendor-native semantics exists.check:auth-mount-ledgerowes a shadowing mount. A mount whose exactMETHOD pathis 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: fullvitest run— 79 files, 1619 tests passed;typecheck(both tsc programs) green after building the package and its dependency closure.admin-has-permission-endpoint.test.tsdrives the REAL mount chain (Hono +registerAuthRoutesin front of a realAuthManageron 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 adminfalse), 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).admin-platform-admin-standing.dogfood.test.tsmoves the route fromNOT_AN_AUTHORIZATION_ANSWERtoADMITTED, with a new pin (C-11900) asserting the real answer in both directions — ran green against the live showcase boot (7 tests). The siblingadmin-route-nonadmin-refusal.dogfood.test.tsis untouched and green (7 tests): the non-admin side holds, including its universal no-2xx invariant and the route's classified negative-answer exception.exportsresolution in between. The mutation and the restore were each confirmed on disk (injected-marker count 0→1→0; restore viagit checkout HEAD -- {path}, then disk == index == HEAD verified).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat9747deb47— 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, afterturbo 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 nomajorbump."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.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.majoris refused in the launch window and nothing here breaks a contract;minorwould overstate an answer correction as new capability.Review gate
Clause-② applies (authorization answer surface):
needs:contract-reviewis 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