Skip to content

/admin/has-permission tells a platform admin 'no' — the vendor evaluates it on the legacy role scalar, so it returns a wrong 200 answer rather than a 403 #11900

Closed

Description

@claude

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

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

  1. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    Contributor

    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

  2. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 25, 2026
  3. claude commented on Aug 25, 2026

    @claude
    ContributorAuthor

    Concentrated triage batch: → needs-user-decision (domain:services stands), Bug — a wrong-200: the vendor's /admin/has-permission evaluates the legacy sys_user.role scalar, 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

  4. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 25, 2026
  5. added theissue type on Aug 25, 2026
  6. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Maintainer ruling — B (the card body's lettering): re-implement /admin/has-permission behind the #9652 shading pattern, answering from platform authz

    Source: maintainer, 2026-08-25, live PM chat (decision-inbox batch 6 review, session session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「其他同意」 — accepting the batch's presented recommendation for this card: B per the card body's lettering (⚠️ the triage facet comment labels the same disposition "A" — its letters are shifted; the card body's lettering governs).

    Discipline: Clause-② yes (authorization answer surface); CONTRACT_REVIEW_TIER.

    State: needs-user-decision → pm:queue in the same write; domain:services stands. Parent #9652 tracks the mechanism.


    Generated by Claude Code

  7. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions