Skip to content

finding: seven better-auth /admin/ routes refuse platform admins and have no ObjectStack consumer — decide whether they are capability or just vendor surface #9969

Closed as not planned

Description

@os-warren

Split out of #9652 while implementing its Option-2 fallback. Filed unassigned; observation, not a repro-and-fix.

#9652 measured that better-auth 1.7.1's admin plugin cannot be pointed at ObjectStack's platform-admin predicate, so every route it gates refuses platform admins. Its PR re-mounts the two that a sys_user action calls (ban-user, unban-user); #9968 carries the two that need a decision (impersonate-user, set-role). These seven are the remainder, and what separates them is that a repo-wide grep for their paths finds no ObjectStack consumer at all — no type: 'api' action, no console call, no SDK row that reaches them, no doc that tells a user to call them. They are surface the rawApp.all(${basePath}/*) catch-all publishes because better-auth registers it.

The greppable list, so this does not have to be re-derived:

POST /api/v1/auth/admin/remove-user
POST /api/v1/auth/admin/revoke-user-session
POST /api/v1/auth/admin/revoke-user-sessions
POST /api/v1/auth/admin/list-user-sessions
POST /api/v1/auth/admin/update-user
GET  /api/v1/auth/admin/list-users
GET  /api/v1/auth/admin/get-user

All seven answer a platform admin 403 YOU_ARE_NOT_ALLOWED_TO_* today, identically to a plain member. They fail closed — this is a broken capability claim, never an escalation.

The decision

Re-implementing all seven as ObjectStack raw mounts is the largest slice of #9652's Option 2 and buys capability nothing in this repo currently pulls. Against that, they ARE published, ledgered (BETTER_AUTH_MOUNTED_SURFACE in auth-route-ledger.ts) and reachable by any client that reads the OpenAPI document, so "advertised but always refuses the only caller entitled to it" is a declared-≠-enforced claim of its own (Prime Directive #10).

Recommendation: B, with A as the standing state — startup scope discipline says do not build seven routes nobody calls, and B makes the trigger explicit so the next author does not have to rediscover why they 403. remove-user is the one worth watching: it is the closest to a real capability (and it carries the break-glass last-local-credential guard in auth-manager.ts's before-hook, which stays intact precisely because that route is not shadowed).

Two mechanics worth recording for whoever picks this up, both measured in #9652:

  1. Shadowing a vendor path with a raw Hono mount silently detaches every better-auth hook keyed on that path. /admin/remove-user carries the break-glass guard. Every better-auth-native /admin/ route refuses ObjectStack platform admins — the vendor gates on the legacy user.role === 'admin' scalar ADR-0068 D2 stopped synthesizing #9652 extracted that guard into last-local-credential.ts so both call sites share one implementation — reuse it rather than reimplementing.
  2. /admin/create-user is the ONLY admin endpoint with the header-less-server-call trust shape (it resolves the session optionally and gates only if (session)). Every other one declares use: [adminMiddleware], which throws UNAUTHORIZED when no session resolves — so the "call it server-side without headers" trick that admin-user-endpoints.ts uses for create-user does not generalise to any of these seven.

Parent: #9652.

Activity

  1. added theissue type on Aug 19, 2026
  2. os-warren commented on Aug 19, 2026

    @os-warren
    CollaboratorAuthor

    Triage: graded pm:blocked · domain:services · type Task, blocked behind the family decision rather than escalated as a second inbox card. Rationale: the maintainer's ruling on #9968 (impersonate-user residue + set-role veto tension, same vendor-admin family, same package) will set the posture for consumerless vendor /admin/ surface; this card's outcome follows it. Expected post-ruling grade: adopt the dev report's recommendation — B with A as the standing state (on-demand re-implementation; record the intended refusal in auth-route-ledger.ts so "advertised, refuses the only entitled caller" stops being an undocumented declared≠enforced claim) — unless the #9968 ruling says otherwise. One decision card per family keeps the inbox lean.

    Blocked-by: #9968


    Generated by Claude Code

  3. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    分诊席四维分析(维护者 2026-08-24 点名要求;裁决仍归维护者)。

    os-decision-facets

    一句话问题:七条 better-auth /admin/ 路由(remove-user、revoke-sessions、list-users 等)对平台管理员一律 403,且全仓零消费者——留着并记录(A)、按需逐条重实现(B)、还是现在补齐七条(C)?

    ① 实际业务需求:实测零 ObjectStack 消费者(无 action、无 console 调用、无 SDK 行、无文档指引)。没有任何拉动。→ A/B。
    ② 项目长远合理性:「广告着却拒绝唯一有权的调用者」确是 declared≠enforced,但它已被台账(BETTER_AUTH_MOUNTED_SURFACE)与 dogfood 的 better-auth-gate 桶钉成已知边界——不是静默漂移;#9652 已确立按需重挂载的成熟模式(ban/unban 已走过)。→ B。
    ③ 防 AI 犯错:403 是 fail-closed,无法被利用;风险只是读 OpenAPI 的集成者预期落空——A 的记录即缓解。反而 C 会新增七条无人调用、无人演练的安全路由(且其中 shadowing 会静默摘掉 vendor hook,remove-user 上挂着 break-glass 守卫)——未使用的安全面是最容易烂的面。→ B,反对 C。
    ④ 创业阶段不扩散需求:C 是七条没人要的路由。→ B。

    推荐:B(第一个出现 ObjectStack 消费者的路由按 #9652 模式当场重挂载),A 为常态记录(四轴同向;即卡面自荐)。 remove-user 单独盯——它离真实能力最近且带 break-glass 守卫。裁后本卡关闭(裁决即记录,pm:blocking 的下游按解锁扫描处理)。本分析看不见什么:您对企业客户管理面的产品规划——若 Setup 用户管理近期要吃这几条能力,直接点名哪几条,按 B 提前触发。


    Generated by Claude Code

  4. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — B, with A as the standing state; closing

    Provenance: maintainer, 2026-08-24, live PM chat, verbatim: 「接受你的建议。」 on the aligned four-facet analysis above (which adopted this card's own recommendation).

    Ruled: the seven better-auth /admin/ routes stay as they are — published, ledgered, refusing platform admins fail-closed, with the refusal pinned by the dogfood better-auth-gate bucket. The moment any of them gains an ObjectStack consumer, that route gets an ObjectStack mount at that moment, using the #9652 pattern (reuse last-local-credential.ts for the break-glass guard; remember shadowing detaches vendor hooks on that path; the create-user header-less trust shape does not generalise). C (build all seven now) is rejected — unexercised security surface. remove-user is the named watch item.

    Closing not planned — the ruling is the record; the B trigger is written above and needs no open card to fire (the first consumer's own card carries the mount work). Downstream Blocked-by: #9969 lines unlock via the standard scan; seats re-verify on current refs.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions