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
Activity
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 -->
- platform long-term coherence: Retiring
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. - measured business pull: Both buttons are measured broken today (byte-identical 403 for admin and plain member). But nothing measured pulls for set-role's only effect — writing the vetoed scalar; impersonation is a real admin-support workflow, though no usage telemetry exists.
- AI-agent error-resistance: A raw re-implementation must hand-roll better-auth's signed-cookie format — a subtly wrong signature is a broken exit path or a forgeable cookie, and the detached path-keyed hook re-introduces 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 with no failing test. Removing the set-role text box removes a one-user-at-a-time resurrection channel for the dual identity.
- startup scope discipline: Remove beats declare-and-maintain for set-role. For impersonate, the cheap first step is the unmeasured probe (
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
- platform long-term coherence: Retiring
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
- added a commit that references this issue
on Aug 19, 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 commented
on Aug 22, 2026 CollaboratorMore actionsH8 配对写入:
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
- impersonate 半边:已落地,contract review PASS(
huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsMaintainer 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:- Retire
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. - Impersonate half: measurement only, no re-implementation yet — establish whether better-auth 1.7.1's
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
- Retire
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.- The removal is exactly the ruled one and nothing beside it: the
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. - The ledgers move with it, not around it:
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. - 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-reviewon both carriers. Enqueue/flip belongs to the dispatching seat's landing window.
Generated by Claude Code
- The removal is exactly the ruled one and nothing beside it: the
- added a commit that references this issue
on Sep 17, 2026
Split out of #9652 while implementing its Option-2 fallback. #9652's PR re-mounts
/admin/ban-userand/admin/unban-userwith the ADR-0068 gate; these two are the remainingsys_useractions 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
internalAdaptercalls each. These two do not.1.
POST /api/v1/auth/admin/impersonate-user— not safely re-implementable at the Hono layerThe 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_sessioncookie payload is a contract with/admin/stop-impersonating, which parsesadminCookie.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-usercarries one —rotateCallerBearerOnImpersonationinauth-manager.ts'safterhook, 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'simpersonate_useraction (locations: ['list_item','record_header'],requiresFeature: 'admin') renders an "Impersonate User" button that returns403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERSfor 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 scalarRe-implementation is one call:
internalAdapter.updateUser(userId, { role }). The problem is what that writes.sys_user.roleis the legacy scalar ADR-0068 D2 retired, and it is still folded into the derived identity —auth-manager.ts'scustomSessionseedspositions[]fromstoredRole.split(','). So a working "Set Platform Role" action is a supported, gated, UI-driven way for an admin to typeadmininto 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:
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.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_userare unaffected (all ObjectStack mounts).Evidence
By-construction, at the #9652 branch point, with the ADR-0068 wiring ablated (i.e. today's
mainbehaviour), seeded dev admin who is a platform admin withsys_user.role = 'user':— the admin and the plain member receive byte-identical refusals, which is the whole defect.
impersonate-userandset-roleanswer the same way with their ownYOU_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).