Skip to content

impersonate_user and set_user_role still 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968

Description

@os-warren

Split out of #9652 while implementing its Option-2 fallback. #9652's PR re-mounts /admin/ban-user and /admin/unban-user with the ADR-0068 gate; these two are the remaining sys_user actions whose targets are still served by better-auth and therefore still refuse platform admins. Filed unassigned — each needs a decision, not just code.

Measured at the installed better-auth 1.7.1 (post PR #9869), on a stock showcase boot with OS_SCIM_ENABLED=true.

Why these two were held back rather than done with their siblings

The vendor cannot be configured (that is #9652's measured branch answer), so the fallback is re-implementation as an ObjectStack raw mount. Ban/unban re-implement cleanly: two internalAdapter calls each. These two do not.

1. POST /api/v1/auth/admin/impersonate-user — not safely re-implementable at the Hono layer

The vendor handler is not a data write. It mints a session and rewrites cookies using helpers that exist only inside a better-auth endpoint context:

  • ctx.context.internalAdapter.createSession(targetId, true, { impersonatedBy, expiresAt }, true)
  • deleteSessionCookie(ctx)
  • ctx.getSignedCookie(authCookies.dontRememberToken.name, ctx.context.secret)
  • ctx.context.createAuthCookie('admin_session') + ctx.setSignedCookie(name, ${callerSessionToken}:${dontRememberMe}, secret, attributes)
  • setSessionCookie(ctx, { session, user }, true)

A raw Hono mount has none of them. Re-implementing means hand-rolling better-auth's signed-cookie format against its secret — and the exact admin_session cookie payload is a contract with /admin/stop-impersonating, which parses adminCookie.split(':') and refuses with a 500 if the shape is off. A subtly wrong signature is either a broken exit path or a forgeable cookie.

Second, independent blocker: shadowing a vendor path with a raw mount silently detaches every better-auth hook keyed on that path. /admin/impersonate-user carries one — rotateCallerBearerOnImpersonation in auth-manager.ts's after hook, which exists because of #8243 (without it the bearer plugin converts the caller's token back into the admin's session on every later request and the impersonation is a 200 no-op). A raw mount would reintroduce #8243 with no test noticing.

User-visible residue: sys_user's impersonate_user action (locations: ['list_item','record_header'], requiresFeature: 'admin') renders an "Impersonate User" button that returns 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS for every platform admin. Fails closed.

Recommended shape if this is taken forward: not a raw Hono mount. Register the replacement as a better-auth plugin endpoint instead, so the cookie helpers, the $context, and ObjectStack's own path-keyed hooks all still apply, and only the authorization predicate changes. Whether better-auth 1.7.1 permits an endpoint that overrides a path another plugin registers (checkEndpointConflicts) is unmeasured and should be measured before committing to it.

2. POST /api/v1/auth/admin/set-role — mechanically easy, but its only effect is the vetoed scalar

Re-implementation is one call: internalAdapter.updateUser(userId, { role }). The problem is what that writes. sys_user.role is the legacy scalar ADR-0068 D2 retired, and it is still folded into the derived identity — auth-manager.ts's customSession seeds positions[] from storedRole.split(','). So a working "Set Platform Role" action is a supported, gated, UI-driven way for an admin to type admin into a text box and resurrect exactly the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3), one user at a time.

That is a governance question, not an implementation one, which is why #9652's PR does not ship it.

Options, no recommendation made unilaterally:

  • A. Leave the route on the vendor gate (status quo): the action stays broken and the veto is not stressed. Cheapest; leaves a dead button.
  • B. Retire the set_user_role action from sys_user — platform-admin membership is granted through sys_user_permission_set / admin_full_access under ADR-0068, so the action arguably describes an identity model that no longer exists. Removes the dead button and the veto tension together.
  • C. Re-implement it, restricted to a vocabulary that excludes anything in the vendor's adminRoles. Keeps the capability, does not resurrect platform admin via the scalar — but keeps two identity representations alive on purpose.

unlock_user, create_user, set_user_password, ban_user, unban_user are unaffected (all ObjectStack mounts).

Evidence

By-construction, at the #9652 branch point, with the ADR-0068 wiring ablated (i.e. today's main behaviour), seeded dev admin who is a platform admin with sys_user.role = 'user':

/auth/admin/ban-user   admin  -> 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"}
/auth/admin/ban-user   member -> 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"}

— the admin and the plain member receive byte-identical refusals, which is the whole defect. impersonate-user and set-role answer the same way with their own YOU_ARE_NOT_ALLOWED_TO_* codes (recorded in #9652's body).

Parent: #9652. Not a duplicate of #9653 (SSO bridges) or #9654 (pre-auth shape probing).

Activity

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

    @os-warren
    CollaboratorAuthor

    Triage: lands in packages/plugins/plugin-auth / packages/platform-objects → domain:services; type Bug (two declared sys_user actions answer 403 to every platform admin). Routed to the maintainer inbox, not the queue: the set-role half decides whether to keep a writer for the ADR-0068-retired scalar (the 2026-08-18 Option-3 veto), and the impersonate half sits on the auth/session boundary — human floor either way.

    <!-- os-decision-facets -->

    Options: A status quo for both (dead buttons, fails closed) · B retire the set_user_role action from sys_user; for impersonate_user, measure the plugin-endpoint override question first, then re-implement as a better-auth plugin endpoint or retire it too · C re-implement set-role with a vocabulary excluding the vendor adminRoles, plus the plugin-endpoint impersonate.

    Recommendation: B. Confidence gap: this analysis cannot see whether any real deployment relies on impersonation (zero usage telemetry), and the plugin-endpoint conflict behaviour on better-auth 1.7.1 is explicitly unmeasured — if the probe fails, the impersonate half of B collapses back into A vs. a much more expensive path.

    Parent #9652 stays the coordination node for the route family; this card decides only these two routes.


    Generated by Claude Code

  3. os-warren commented on Aug 19, 2026

    @os-warren
    CollaboratorAuthor

    Triage: pm:blocking derived — two open cards now carry Blocked-by: lines pointing here (#9652, the coordination parent, and #9969, the consumerless-routes sibling), so this decision card has open downstream dependents; flagged in the round brief per the standing decision-inbox rule. Also assigned os-zhuang per the 2026-08-19 notification-handover ruling (assignment = inbox routing, not a claim). The four-facet block and recommendation are already on this card from the 2026-08-19 morning triage grading — unchanged.


    Generated by Claude Code

  4. removed their assignment
    on Aug 20, 2026
  5. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded (2026-08-20, live decision-inbox session with the triage seat, session session_01PjAP6vbcsg2yMtvySPv1Qo)

    Ruled: B — matching the facet block's recommendation; maintainer accepted the batch, verbatim: 「其他接受你的建议。」(this card's presented recommendation: retire set_user_role; implement impersonate measure-first).

    1. set_user_role: retire the action from sys_user. Its only effect is writing the ADR-0068-vetoed legacy scalar — a supported UI resurrection channel for the dual identity. Removal, not re-implementation. (Clause-② note: removing a declared action narrows the public surface — declare Clause-②: yes at claim; contract-review tier applies.)
    2. impersonate_user: measure first, then implement as a better-auth plugin endpoint — probe whether 1.7.1's checkEndpointConflicts permits overriding a path another plugin registers; if yes, re-implement with only the authorization predicate changed (cookie helpers, $context, and the better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243 rotation hook all stay attached). ⛔ Raw Hono mount is forbidden (hand-rolled signed cookies + silently detached hooks). If the probe fails, stop and report — the fallback choice (A vs. a costlier path) returns here rather than being improvised.

    State transition in the same stroke: needs-user-decision → pm:queue (pm:blocking retained — #9652/#9969 depend).


    Generated by Claude Code

  6. 10 remaining items

  7. os-project-manager commented on Aug 22, 2026

    @os-project-manager
    Collaborator

    H8 配对写入:pm:dispatched → needs-user-decision

    交付 PR #10352 已于 2026-08-21T04:56:09Z 合并(impersonate 半边),本卡仍挂 pm:dispatched —— 合并的配对写入没落。半状态巡查(锚 #9857,2026-08-22T07:49Z 那轮)本轮已把本卡列为 H8。

    按卡上已有的判定重新定级,不新增判断:

    • impersonate 半边:已落地,contract review PASS(5363785802)。
    • set_user_role 退役半边:停在 packages/spec 红线前,判断已上交维护者(dev 报告 Q1 的 A / B / C;services 席明确不自采纳,理由是「当『这一次不一样』的论证恰好通向我方便的答案时,采纳它的动机是坏的」)。

    ⇒ 剩余物是一个等裁决的分叉,不是可派发工作,故 needs-user-decision。pm:blocking 保留 —— #9652 与 #9969 仍压在本卡下面。

    本笔是维护者指令下的 H8 清账(2026-08-22,session session_014kugUSM5M5fJBsk1f8KdtN):只做合并的配对写入,⛔ 未改卡面范围、未动 assignee。


    Generated by Claude Code

  8. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling recorded — set-role: B (retire the action) · impersonate: measure first

    Provenance: maintainer, 2026-08-22, live session with the triage seat (「同意所有」 on the 18-item digest; item 4 = B + 测).

    State: needs-user-decision → pm:queue (pm:blocking stands; claim continues with the current assignee). Two-part scope:

    1. Retire set_user_role from sys_user's actions — ruled: the action describes an identity model ADR-0068 D2 retired, and a working version would be a gated UI path for resurrecting the permanently-vetoed dual representation (2026-08-18 Option 3 veto). Removal of the dead button + its action declaration, with the standard retirement discipline for a published platform-object action.
    2. Impersonate half: measurement only, no re-implementation yet — establish whether better-auth 1.7.1's checkEndpointConflicts permits a plugin endpoint overriding a vendor-registered path (the card's own recommended shape). Report the reading back here; the build decision returns to the maintainer with that fact. ⛔ A raw Hono mount stays ruled out on the card's two recorded blockers (cookie contract with stop-impersonating, silent detachment of the better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243 hook).

    Generated by Claude Code

  9. claude commented on Aug 24, 2026

    @claude
    Contributor

    Contract review — PASS (this PR's increment — the set_user_role retirement half) (fable seat, session 5213b871-5164-5bc3-8874-28b336bbcd40, hourly sub-round; fuse reading get_session → external_metadata.last_served_model = claude-fable-5, matching CONTRACT_REVIEW_TIER read from origin/main; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」). Independence: not this seat's dispatch — independent review.

    Reviewed PR #11530 @ 4d51295416 against the actual diff. Rulings verified on this card: 2026-08-20 「Ruled: B」 and 2026-08-22 「set-role: B (retire the action) · impersonate: measure first」 — the removal-of-published-capability floor is satisfied by explicit maintainer ruling, twice.

    • The removal is exactly the ruled one and nothing beside it: the sys_user console action block goes; the vendor POST /admin/set-role route stays mounted and vendor-gated byte-for-byte; all 15 sibling admin actions survive — pinned BY NAME in one test together with the retired name's absence, so a wrong-entry removal cannot pass.
    • The ledgers move with it, not around it: PUBLIC_AUTH_FEATURES.admin.gatedInputs drops the entry (the bidirectional completeness guard forces this — not a discretionary spec edit); the feature-gate walker floor 38→37 with the reason in the comment; i18n bundles regenerated by the tool, never hand-edited.
    • Half 2 correctly untouched: the impersonate premise is measured FALSE on current main (fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 merged 2026-08-21, reviewed and PASSed by the triage seat then) — Part of, not a closing keyword, is the right form while the card's premise update is the dispatching seat's to record.
    • Note for the dispatching seat: the dev died to an infrastructure fault with a partial report (02:37Z note above) — the PR itself is complete and this review read the PR, not the report; the direct-acceptance fallback applies on your side.

    Verdict: PASS. Clearing needs:contract-review on both carriers. Enqueue/flip belongs to the dispatching seat's landing window.


    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