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

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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions