Repository navigation
Description
Measured 2026-08-24 while pinning the platform admin's answer on every /admin/ route (#9482, PR #11899). Filed unassigned; not fixed there — that is a tests-only card and this needs a decision about a route, not a test.
What happens
On a stock showcase boot with the better-auth admin plugin on (OS_SCIM_ENABLED=true, the ADR-0071 derivation), the seeded platform admin asks the vendor's own permission-query endpoint whether he may list users, and is told no:
POST /api/v1/auth/admin/has-permission {"permissions":{"user":["list"]}}
as the platform admin -> 200 {"error":null,"success":false}
as a plain member -> 200 {"error":null,"success":false}
Byte-identical answers for a platform admin and a plain member. The admin identity is correct in every ObjectStack sense — get-session returns positions: ["user","platform_admin"], isPlatformAdmin: true — and sys_user.role is 'user'.
Why
The same mechanism as #9652, which is this card's parent: better-auth's admin plugin evaluates hasPermission against the legacy user.role === 'admin' scalar (constructed as admin({ schema }) only, so the vendor default adminRoles: ['admin'] applies), and ADR-0068 D2 deliberately stopped synthesizing that scalar.
Why it is not already covered by #9652 or #9969
Both of those enumerate routes that refuse with 403 YOU_ARE_NOT_ALLOWED_TO_*. This one does not refuse — it returns 200 and answers the question wrongly. It therefore fell outside #9652's listed eight and outside the seven #9969 closed not_planned, and no card names it.
The distinction matters for how it fails. A 403 is a broken button someone notices. A 200 {"success":false} is an authoritative-looking negative answer: any caller that trusts it as "this admin may not do X" gets a wrong answer with no error to log. It still fails closed, so this is a broken capability claim and not an escalation — QA run #9401's "no access-control hole" verdict is unaffected.
Consumer status
Repo-wide grep for the path finds no ObjectStack consumer — no type: 'api' action, no console call, no SDK row, no doc telling anyone to call it. It is published surface: BETTER_AUTH_MOUNTED_SURFACE in packages/plugins/plugin-auth/src/auth-route-ledger.ts:354 ledgers it, and it is reachable by any client reading the OpenAPI document.
That makes it the same shape as the seven #9969 ruled on, and the cheapest resolution is probably the same one: record that the answer is known and intended pending pull, with re-implement-on-demand standing. The reason to file it separately rather than assume that is that #9969's reasoning was written about refusals — "advertised but always refuses the only caller entitled to it" — and a route that instead returns a confident wrong answer may deserve a different call. If a consumer ever appears, has-permission is more dangerous to leave wrong than a 403 route is, because the caller cannot tell it is wrong.
Options
- A. Leave it, matching finding: seven better-auth
/admin/routes refuse platform admins and have no ObjectStack consumer — decide whether they are capability or just vendor surface #9969's ruling for the consumer-less family, and record the answer as known/intended here so the next reader does not re-derive it. - B. Re-implement it as an ObjectStack mount answering through the ADR-0068 predicate, as
create-user/set-user-password/ban-user/unban-useralready are. - C. Stop publishing it — drop it from the catch-all surface so nothing can read a wrong answer from it.
No recommendation made unilaterally: the choice turns on whether a permission query that answers wrongly is in the same class as a permission gate that refuses, which is the question #9969 did not have in front of it.
Where it is recorded today
packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts (PR #11899) classifies it in NOT_AN_AUTHORIZATION_ANSWER with this measurement in the note, so the sweep does not read its 200 as evidence that authorization is fine. The sibling admin-route-nonadmin-refusal.dogfood.test.ts already pins the non-admin side (200 with success:false is the correct negative answer for a plain member).
Parent: #9652.
Generated by Claude Code
Activity
user.role === 'admin'scalar ADR-0068 D2 stopped synthesizing #9652os-zhuang commentedon Aug 25, 2026
Triage: routed
finding+domain:services, awaiting first-touch grading. Rationale: fails closed, zero in-repo consumers, and PR #11899 already pins the measurement (NOT_AN_AUTHORIZATION_ANSWER) — nobody hits this today, so it is observation-class rather than a queue Bug. Grading note: the #9969 precedent (leave the consumer-less better-auth-native family pending pull, re-implement on demand) inherits by default, but this card argues a genuine semantic difference (confident wrong 200 vs a visible 403) — that is exactly the re-check the inheritance rule requires before reusing the mother ruling. If grading concludes "act" (options B/C), this touches an auth/permission surface, which is the manual floor: it goes to the decision inbox, not the queue. Parent #9652 tracks the underlying mechanism.Generated by Claude Code
claude commentedon Aug 25, 2026
Concentrated triage batch: →
needs-user-decision(domain:servicesstands), Bug — a wrong-200: the vendor's/admin/has-permissionevaluates the legacysys_user.rolescalar, so the seeded platform admin and a plain member get byte-identical "no". Permission-surface semantics: maintainer floor.① 项目长远合理性:平台的权限真相是 ObjectStack 的 authz(positions/isPlatformAdmin),vendor 端点答的是另一套(legacy role 标量)—— 两套真相并存,错的一侧还挂在公开 API 上;收敛到单一真相与 #9652 遮蔽先例同向。
② 实际业务拉动:任何用 vendor 端点做「我能不能」预检的集成/前端,会把平台管理员当普通人 —— 按钮灰掉、流程拒走,而 get-session 又说他是管理员,两个官方答案互相矛盾。
③ 防 AI 犯错:AI 写前端预检时最自然就是调这个端点;wrong-200 静默教它写出错误权限逻辑 —— 遮蔽后从平台 authz 作答,答案与真实执行一致。
④ 创业阶段不扩散:A 案复用既有 raw-mount 遮蔽模式(#9652),答案从已有 authz 推导,零新权限模型;B 案(把派生角色喂进 vendor 的 ac 配置)动 vendor 配置面,SCIM 迁移(#11632)期间尤其不宜再加一层。
推荐:A = 以 #9652 模式遮蔽
/admin/has-permission,从 ObjectStack authz 作答(platform admin ⇒ true,其余按现有映射);B = 喂 vendor ac 配置;C = 文档注记 defer。回一个字母即可。置信缺口:未盘点是否有消费方依赖该端点的 vendor 原生语义(含 better-auth 客户端 SDK 内部自用);A 落地前需一次消费面 grep + 对 #11632 迁移分支的碰撞检查。
Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
Maintainer ruling — B (the card body's lettering): re-implement
/admin/has-permissionbehind the #9652 shading pattern, answering from platform authzSource: maintainer, 2026-08-25, live PM chat (decision-inbox batch 6 review, session⚠️ the triage facet comment labels the same disposition "A" — its letters are shifted; the card body's lettering governs).
session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「其他同意」 — accepting the batch's presented recommendation for this card: B per the card body's lettering (create-user/set-user-password/ban-user/unban-useralready are, answering from the ADR-0068 predicate: a platform admin's query returns the answer real execution would give; the wrong-200 (byte-identical "no" for a platform admin and a plain member, evaluated on the legacy role scalar) dies. Same family and same fix shape as [security][finding] 同一 break-glass 守卫的第三张面孔:/admin/remove-user 上全局 hooks.before 跑在 adminMiddleware 之前,已认证的非管理员即可触达其查找与拒绝 #11477, ruled this same day./admin/routes refuse platform admins and have no ObjectStack consumer — decide whether they are capability or just vendor surface #9969 precedent) is not taken — the precedent's reasoning was written for visible refusals, and a confident wrong answer on a permission query is the more dangerous shape if a consumer ever appears, because the caller cannot tell it is wrong. C (unpublish) is not taken — vendor-SDK internal use of the route is unmeasured.NOT_AN_AUTHORIZATION_ANSWERreclassifies to a real answer when this lands; the non-admin side (success:falsefor a plain member) must stay green.Discipline: Clause-② yes (authorization answer surface);
CONTRACT_REVIEW_TIER.State:
needs-user-decision→pm:queuein the same write;domain:servicesstands. Parent #9652 tracks the mechanism.Generated by Claude Code