Repository navigation
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
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_roleaction fromsys_user— platform-admin membership is granted throughsys_user_permission_set/admin_full_accessunder 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
user.role === 'admin'scalar ADR-0068 D2 stopped synthesizing #9652os-warren commentedon Aug 19, 2026
Triage: lands in
packages/plugins/plugin-auth/packages/platform-objects→domain:services; type Bug (two declaredsys_useractions 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 -->
set_user_role(B) shrinks the retired dual-identity surface — ADR-0068 moved platform-admin membership tosys_user_permission_set, and a supported UI writer for the legacy scalar re-grows exactly that contract. For impersonation, a better-auth plugin endpoint (not a raw Hono mount) is the only shape that keeps the signed-cookie contract with/admin/stop-impersonatingand 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 attached.checkEndpointConflictsoverride behaviour), not a commitment to re-implement.Options: A status quo for both (dead buttons, fails closed) · B retire the
set_user_roleaction fromsys_user; forimpersonate_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 vendoradminRoles, 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
os-warren commentedon Aug 19, 2026
Triage:
pm:blockingderived — two open cards now carryBlocked-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 assignedos-zhuangper 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
feat(pm): H4/H14/blocking-index read comment-level Blocked-by lines (#…
os-zhuang commentedon Aug 20, 2026
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).set_user_role: retire the action fromsys_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 — declareClause-②: yesat claim; contract-review tier applies.)impersonate_user: measure first, then implement as a better-auth plugin endpoint — probe whether 1.7.1'scheckEndpointConflictspermits 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:blockingretained — #9652/#9969 depend).Generated by Claude Code
10 remaining items
os-project-manager commentedon Aug 22, 2026
H8 配对写入:
pm:dispatched→needs-user-decision交付 PR #10352 已于 2026-08-21T04:56:09Z 合并(impersonate 半边),本卡仍挂
pm:dispatched—— 合并的配对写入没落。半状态巡查(锚 #9857,2026-08-22T07:49Z 那轮)本轮已把本卡列为 H8。按卡上已有的判定重新定级,不新增判断:
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
huangyiirene commentedon Aug 22, 2026
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:blockingstands; claim continues with the current assignee). Two-part scope:set_user_rolefromsys_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.checkEndpointConflictspermits 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 withstop-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
feat(pm): H4/H14/blocking-index read comment-level Blocked-by lines (o…
fix(plugin-auth): admit ObjectStack platform admins on /admin/imperso…
claude commentedon Aug 24, 2026
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 readingget_session→external_metadata.last_served_model=claude-fable-5, matchingCONTRACT_REVIEW_TIERread from origin/main; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」). Independence: not this seat's dispatch — independent review.Reviewed PR #11530 @
4d51295416against 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.sys_userconsole action block goes; the vendorPOST /admin/set-roleroute 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.PUBLIC_AUTH_FEATURES.admin.gatedInputsdrops 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.Part of, not a closing keyword, is the right form while the card's premise update is the dispatching seat's to record.Verdict: PASS. Clearing
needs:contract-reviewon both carriers. Enqueue/flip belongs to the dispatching seat's landing window.Generated by Claude Code
fix(platform-objects): point `sys_user.role`'s prose at the path that…
fix(platform-objects): sys_user.role help text names the platform-adm…
fix(platform-objects): translate sys_user.role help text away from th…
fix(spec): the error-code waiver reasons and the auth-feature registr…
docs(qa): park the 24 partial-coverage gaps of the 17.7 pre-release r…