Skip to content

Commit 5b0af2b

Browse files
os-warrenclaude
andauthored
fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) (#10352)
* fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user better-auth's admin plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a platform admin and a plain member received byte-identical 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS and the sys_user "Impersonate User" button was dead everywhere. Re-authorize the route as a better-auth PLUGIN ENDPOINT, replacing the vendor endpoint in place on the admin plugin's own `endpoints` record, rebuilt from the vendor's own options object so only the authorization predicate changes. A raw Hono mount is forbidden: it means hand-rolled signed cookies against the `admin_session` contract with /admin/stop-impersonating, and it would silently detach the path-keyed #8243 rotation hook. Measured on better-auth 1.7.1: `checkEndpointConflicts` only logs, so a second plugin would boot and serve but print an endpoint-conflict error on every start; replacing in place keeps exactly one plugin on the path. The vendor's admin-TARGET guard read the same dead scalar and was inert; it is re-asked through the ADR-0068 predicate so it means something again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * test(plugin-auth): record why the impersonation suite reuses the sibling engine double Importing a sibling `.test.ts` re-registers its suites here. That cost is real and is now written down alongside the three worse alternatives, so the next reader does not "fix" it into a new engine double, a ledger-invisible helper, or a suite-free fixture file this package's bare `vitest run` cannot load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * chore(devx): declare the ruled vendorWire entry for admin-impersonate-endpoint.ts The 2026-08-21 maintainer ruling (#10554, option A) added a fourth state to check-route-envelope's surface-3 grammar for a body this repo BUILDS whose shape is a vendor's wire format. The machinery landed on `main` in 6abc4df with no entries, deliberately: an entry for a file the walk cannot find is an error, so the entry lands with the file, here. The one counted body is the success return of POST /admin/impersonate-user, `ctx.json({ session, user })`. The four refusals are `throw APIError.from(…)`, which no counter on this surface reads, so `unenveloped: 1` is the whole visible departure. The body is byte-identical to better-auth 1.7.1's own handler return, and this endpoint republishes the vendor's OpenAPI metadata untouched — a schema declaring exactly `{ session, user }` — so enveloping it would contradict the schema the same endpoint serves. The note names the three machine-checked parties the ruling mandates: vendor, reader and partner. The const-hoist evasion the ruling named is not used: the body literal stays at the call site, visible to every counter. node scripts/check-route-envelope.mjs --self-test ✓ check-route-envelope self-test passed node scripts/check-route-envelope.mjs ✓ Plugin-mounted Hono routes — 12 module(s) audited, 166 hand-built body/bodies (count reported, NOT pinned): 8 conformant, 0 ratcheted, 3 exempt, 1 vendor-wire Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * chore(runtime): classify the four better-auth impersonation codes as foreign-vocabulary `check-dispatcher-error-vocabulary` reported four unclassified-site findings in the new `admin-impersonate-endpoint.ts`: FAILED_TO_CREATE_USER, USER_NOT_FOUND, YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS and YOU_CANNOT_IMPERSONATE_ADMINS. All four are better-auth 1.7.1's OWN constants -- verified in the installed vendor at `dist/plugins/admin/error-codes` and BASE_ERROR_CODES -- and three are read at runtime off `plugin.$ERROR_CODES`. They became visible to this scan only because #9968 reimplements the vendor's handler in-repo, so codes that used to be relayed from node_modules are now stamped by a literal this repo builds. The verdict is `foreign-vocabulary`, door `none`, which is the limb this table already uses twice for better-auth codes in this same package (IMPERSONATION_ROTATION_FAILED, YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER). Re-verified rather than inherited: the refusals are `APIError` thrown inside a better-auth endpoint, better-auth answers its own failures with a `Response`, `AuthManager.handleRequest` returns it untouched (logging only >= 500) and `domains/auth.ts` passes it on as `{ handled: true, result: response }`, so `errorFromThrown` is never reached; anything the auth service does throw is answered `deps.error(INTERNAL_ERROR_MESSAGE, 500)` with a status-derived code (#5085). The 2026-08-21 ruling (#10554) already recorded this same file's bodies as the vendor's wire via `check-route-envelope`'s `vendorWire` entry. `pending-registration` was considered and rejected as FALSE: it asserts the code belongs in #8846's ObjectStack ledger batch. These are the vendor's strings; registering them would promote a vendor spelling into the platform vocabulary and leave a ledger member outliving its producer on the next bump. The rows add zero to PENDING_LEDGER_REGISTRATION, which still holds only `owd_widening_forbidden`. No evasion: no rename, no indirection, no hoist -- every literal stays at its call site, visible to the scanner. node scripts/check-dispatcher-error-vocabulary.mjs check-dispatcher-error-vocabulary: OK -- 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846). node scripts/check-dispatcher-error-vocabulary.mjs --self-test check-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK (vocabulary + #9098 door typing) node scripts/check-nul-bytes.mjs check-nul-bytes: OK (scanned 6193 text file(s) ... no raw ASCII control bytes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76deca2 commit 5b0af2b

7 files changed

Lines changed: 860 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
**Fix:** `POST /api/v1/auth/admin/impersonate-user` now admits ObjectStack **platform admins**. It previously refused every one of them with `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS` — byte-identical to the refusal a plain member received — so the `sys_user` "Impersonate User" button was dead on every deployment (#9968).
6+
7+
better-auth's `admin` plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing. ObjectStack's platform admin is a `sys_user_permission_set` row pointing at `admin_full_access` with `organization_id = null`, which the vendor cannot be pointed at, and re-synthesizing the scalar is permanently vetoed.
8+
9+
**What an operator will now observe.** A platform admin who could not impersonate anyone can now impersonate a non-admin user, and the impersonation takes effect for cookie and bearer clients alike. Refusals are unchanged for everyone else: a signed-in non-platform-admin (including an organization owner or admin, who is **not** a platform admin under ADR-0068) still gets `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`, and an anonymous caller still gets `401` from better-auth's own `adminMiddleware`.
10+
11+
**One refusal is newly reachable.** The vendor refuses to impersonate an admin-grade *target* by reading that same `role` scalar against `adminRoles: ['admin']` — a column nothing writes after ADR-0068 D2, so the guard was inert. It is now asked through the ADR-0068 predicate, so impersonating a **platform-admin target** is refused with `403 YOU_CANNOT_IMPERSONATE_ADMINS` where it previously succeeded.
12+
13+
Implemented as a better-auth **plugin endpoint**, replacing the vendor endpoint in place on the `admin` plugin's own `endpoints` record — not a raw Hono mount. That keeps the signed-cookie contract with `/admin/stop-impersonating` and keeps the `/admin/impersonate-user` path-keyed rotation hook attached, so bearer-client impersonation does not regress to a silent 200 no-op.
14+
15+
Every other better-auth-native `/admin/*` route still gates on the legacy scalar and still refuses platform admins — unchanged here.

packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts

Lines changed: 377 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `POST /admin/impersonate-user`, re-authorized on the ADR-0068 D2 platform-admin
5+
* predicate — as a better-auth **plugin endpoint**, never a raw Hono mount.
6+
*
7+
* ## Why this file is not `admin-ban-endpoints.ts`
8+
*
9+
* Every other ObjectStack `/admin/*` route is a raw Hono mount carrying
10+
* `judgePlatformAdmin` (see `platform-admin-gate.ts`). Ban/unban re-implement
11+
* that way cleanly because they are DATA WRITES: two `internalAdapter` calls
12+
* each, nothing about the caller's own session changes.
13+
*
14+
* Impersonation is not a data write. It mints a session and rewrites cookies
15+
* using helpers that exist ONLY inside a better-auth endpoint context —
16+
* `ctx.getSignedCookie` / `ctx.setSignedCookie` against `ctx.context.secret`,
17+
* `ctx.context.createAuthCookie`, `deleteSessionCookie(ctx)`,
18+
* `setSessionCookie(ctx, …)`. A raw mount has none of them, so re-implementing
19+
* there means hand-rolling better-auth's signed-cookie format. And the exact
20+
* `admin_session` payload is a CONTRACT with `/admin/stop-impersonating`, which
21+
* parses `adminCookie.split(':')` and answers 500 if the shape is off: a subtly
22+
* wrong signature is either a broken exit path or a forgeable cookie.
23+
*
24+
* Second, independent reason — and the one no test would have caught. Shadowing
25+
* a vendor path with a raw mount silently detaches every better-auth hook keyed
26+
* on that path. `/admin/impersonate-user` carries one:
27+
* `rotateCallerBearerOnImpersonation` (#8243) in `auth-manager.ts`'s global
28+
* `hooks.after`. Without it, `bearer()` converts the caller's token back into
29+
* the ADMIN's session on every later request and impersonation is a silent 200
30+
* no-op. A raw mount would reintroduce #8243 with nothing turning red.
31+
*
32+
* ⛔ A raw Hono mount for this route is FORBIDDEN (maintainer ruling,
33+
* 2026-08-20). This module is the shape that ruling names.
34+
*
35+
* ## The measurement this shape rests on
36+
*
37+
* Whether better-auth 1.7.1 permits overriding a path another plugin registers
38+
* was explicitly UNMEASURED when this was ruled. Measured now, on the installed
39+
* `better-auth@1.7.1`:
40+
*
41+
* - `checkEndpointConflicts` (`dist/api/index.mjs`) builds its registry by
42+
* iterating `options.plugins[].endpoints` and, on a duplicate path+method,
43+
* calls `logger.error(...)` — it does **not** throw. A second plugin
44+
* registering `/admin/impersonate-user` therefore BOOTS, serves, and prints
45+
* `Endpoint path conflicts detected!` on every start.
46+
* - `getEndpoints` merges with `{...acc, ...plugin.endpoints}` (key-keyed), and
47+
* `better-call`'s router calls rou3 `addRoute` per endpoint in object order,
48+
* where a later entry for the same method+path REPLACES the earlier one.
49+
*
50+
* So a second plugin works but is permanently noisy. This module takes the
51+
* strictly better door the same measurement opens: it replaces the endpoint
52+
* **on the admin plugin's own `endpoints` record**, so exactly ONE plugin ever
53+
* registers the path. `checkEndpointConflicts` sees one entry, logs nothing,
54+
* and the route is served by an endpoint built with `createAuthEndpoint` —
55+
* inside the endpoint context, with every cookie helper and `$context` present,
56+
* on the same path, so the #8243 hook still fires.
57+
*
58+
* ## What actually changed vs. the vendor handler
59+
*
60+
* The endpoint is rebuilt from the vendor endpoint's OWN `options` object
61+
* (`method`, `body` schema, `use: [adminMiddleware, …]`, `metadata`), passed
62+
* through untouched. So the request contract, the 401-for-anonymous, the
63+
* OpenAPI entry and the body validation are the vendor's, and they cannot drift
64+
* from it on a dependency bump — there is no second copy to drift.
65+
*
66+
* Only the AUTHORIZATION changes, in the two places the vendor asks it:
67+
*
68+
* 1. **Caller.** `hasPermission({ role: session.user.role, … })` → the ADR-0068
69+
* D2 predicate. The vendor's only two authorization inputs are a
70+
* construction-time `adminUserIds` array and the persisted legacy `role`
71+
* scalar; ObjectStack's platform admin is neither (it is a
72+
* `sys_user_permission_set` row pointing at `admin_full_access` with
73+
* `organization_id = null`), and ADR-0068 D2 forbids synthesizing the
74+
* scalar the vendor can read. That mismatch is the whole defect: a platform
75+
* admin and a plain member receive byte-identical
76+
* `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`.
77+
*
78+
* 2. **Target.** The vendor refuses to impersonate an admin-grade target by
79+
* reading `targetUser.role` against `adminRoles` (default `['admin']`).
80+
* On any post-ADR-0068-D2 deployment NOTHING writes that scalar, so that
81+
* guard is INERT — "you cannot impersonate admins" is currently a promise
82+
* the code does not keep. It is re-asked here through the same ADR-0068
83+
* predicate, so it means something again.
84+
*
85+
* Direction matters and is asserted in both: the caller set only ever GROWS by
86+
* platform admins (the legacy `role === 'admin'` reading is retained exactly as
87+
* `platform-admin-gate.ts` retains it, so a deployment still carrying the
88+
* pre-D2 scalar is not locked out), and the protected-target set only ever
89+
* GROWS. Neither predicate admits anyone the vendor admitted and we now refuse,
90+
* and neither refuses anyone the vendor admitted.
91+
*
92+
* ⛔ The vendor's `allowImpersonatingAdmins` / `impersonate-admins` escape is
93+
* deliberately NOT carried over: ObjectStack constructs `admin({ schema })` and
94+
* configures neither, and the escape's own check reads the same dead scalar.
95+
* Re-adding it would be adding a door nothing asked for.
96+
*
97+
* ## Refusal envelope — the vendor's, on purpose
98+
*
99+
* These refusals keep better-auth's flat `{ message, code }` shape and the
100+
* vendor's OWN code constants, read off the plugin's `$ERROR_CODES` rather than
101+
* retyped. This route is a better-auth endpoint, not an ObjectStack raw mount,
102+
* and the dogfood sweep distinguishes the two envelopes on purpose (see
103+
* `admin-ban-endpoints.ts`). Keeping them also mints no new public error code,
104+
* so nothing here reaches the spec error-code ledger.
105+
*/
106+
107+
import { isPlatformAdminUser } from './platform-admin-gate.js';
108+
import { IMPERSONATE_USER_PATH, ADMIN_SESSION_COOKIE_KEY } from './impersonation-bearer-rotation.js';
109+
110+
/**
111+
* Answers ADR-0068 D2's "is this user id a platform admin?" — a
112+
* `sys_user_permission_set` row pointing at `admin_full_access` with
113+
* `organization_id = null`.
114+
*
115+
* Injected rather than imported so this module never reaches for a data engine
116+
* itself. MUST fail CLOSED (resolve `false`) on any lookup error: it backs a
117+
* security gate, and an unverifiable actor must never pass.
118+
*/
119+
export type PlatformAdminOracle = (userId: string) => Promise<boolean>;
120+
121+
/** The slice of better-auth's `admin` plugin this module rewrites. */
122+
export interface AdminPluginLike {
123+
id: string;
124+
endpoints: Record<string, any>;
125+
$ERROR_CODES?: Record<string, { code: string; message: string }>;
126+
}
127+
128+
/**
129+
* Impersonation session lifetime, seconds — the vendor's default for a plugin
130+
* constructed WITHOUT `impersonationSessionDuration`, which is how
131+
* `auth-manager.ts` constructs it (`admin({ schema })`). Pinned rather than
132+
* inherited because the vendor never exposes the resolved option.
133+
*/
134+
export const IMPERSONATION_SESSION_SECONDS = 3600;
135+
136+
/**
137+
* better-auth's `BASE_ERROR_CODES.USER_NOT_FOUND`, restated locally.
138+
*
139+
* ⛔ NOT read from `import('better-auth')` — that is the package ROOT entry, and
140+
* pulling it in here makes admin-plugin CONSTRUCTION depend on it. Several
141+
* suites in this package mock the root (`vi.mock('better-auth', …)`) to capture
142+
* the `betterAuth()` config, and vitest THROWS on a missing export from a
143+
* mocked module. Measured: the whole `admin` plugin was then swallowed by
144+
* `addOptionalPlugin`'s catch and silently disabled — a plugin lost to a
145+
* constant. The subpath entries this module does import
146+
* (`better-auth/api|cookies|db`) are not mocked anywhere and stay real.
147+
*
148+
* A restated constant is only safe if it cannot drift, so it does not rely on
149+
* being remembered: `admin-impersonate-endpoint.test.ts` pins it equal to the
150+
* vendor's own value, and a vendor rename turns that red.
151+
*/
152+
export const USER_NOT_FOUND = { code: 'USER_NOT_FOUND', message: 'User not found' } as const;
153+
154+
/**
155+
* Replace `admin`'s `/admin/impersonate-user` with the ADR-0068-authorized
156+
* endpoint, in place, on the plugin's own `endpoints` record.
157+
*
158+
* Returns the SAME plugin object (mutated), so the plugin's id, schema, hooks,
159+
* `$ERROR_CODES` and every other endpoint stay exactly as the vendor built
160+
* them, and only one plugin ever claims the path.
161+
*
162+
* A vendor bump that renames or drops the endpoint leaves the plugin untouched
163+
* and reports `false` — loudly handled by the caller — rather than silently
164+
* adding a second endpoint nobody routes to.
165+
*/
166+
export async function applyPlatformAdminImpersonation(
167+
plugin: AdminPluginLike,
168+
isPlatformAdmin: PlatformAdminOracle,
169+
): Promise<boolean> {
170+
const vendor = plugin?.endpoints?.impersonateUser;
171+
if (!vendor || vendor.path !== IMPERSONATE_USER_PATH || !vendor.options) return false;
172+
173+
const [{ createAuthEndpoint, APIError }, { deleteSessionCookie, setSessionCookie }, { parseUserOutput }] =
174+
await Promise.all([
175+
import('better-auth/api'),
176+
import('better-auth/cookies'),
177+
import('better-auth/db'),
178+
]);
179+
180+
const ERR = plugin.$ERROR_CODES ?? {};
181+
const notAllowed = ERR.YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS ?? {
182+
code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS',
183+
message: 'You are not allowed to impersonate users',
184+
};
185+
const cannotImpersonateAdmins = ERR.YOU_CANNOT_IMPERSONATE_ADMINS ?? {
186+
code: 'YOU_CANNOT_IMPERSONATE_ADMINS',
187+
message: 'You cannot impersonate admins',
188+
};
189+
const failedToCreate = ERR.FAILED_TO_CREATE_USER ?? {
190+
code: 'FAILED_TO_CREATE_USER',
191+
message: 'Failed to create user',
192+
};
193+
194+
// The vendor's own options object — method, body schema, `use`
195+
// (adminMiddleware: authoritative session or 401) and OpenAPI metadata — is
196+
// handed straight back to `createAuthEndpoint`. Nothing about the request
197+
// contract is retyped here, so nothing about it can drift.
198+
plugin.endpoints.impersonateUser = createAuthEndpoint(
199+
IMPERSONATE_USER_PATH,
200+
vendor.options,
201+
async (ctx: any) => {
202+
const caller = ctx.context.session?.user;
203+
const callerId = typeof caller?.id === 'string' ? caller.id : '';
204+
205+
// ── THE changed predicate (1/2): who may impersonate ─────────────────
206+
// `isPlatformAdminUser` reads the session user we already hold — which
207+
// inside an endpoint is the RAW row (better-auth's `adminMiddleware`
208+
// re-reads the session from the database with the cookie cache disabled,
209+
// so `customSession`'s derived `positions[]` / `isPlatformAdmin` are NOT
210+
// on it). It therefore contributes only the legacy `role === 'admin'`
211+
// back-compat reading, and the oracle does the real ADR-0068 lookup.
212+
const callerAdmitted =
213+
isPlatformAdminUser(caller) || (callerId ? await isPlatformAdmin(callerId) : false);
214+
if (!callerAdmitted) throw APIError.from('FORBIDDEN', notAllowed);
215+
216+
const targetUser = await ctx.context.internalAdapter.findUserById(ctx.body.userId);
217+
if (!targetUser) throw APIError.from('NOT_FOUND', USER_NOT_FOUND);
218+
219+
// ── THE changed predicate (2/2): who is protected FROM impersonation ──
220+
// Same question the vendor asks against `adminRoles`, asked so it is not
221+
// inert after ADR-0068 D2 stopped writing the scalar it read.
222+
const targetId = typeof targetUser.id === 'string' ? targetUser.id : String(targetUser.id);
223+
const targetProtected =
224+
isPlatformAdminUser(targetUser) || (await isPlatformAdmin(targetId));
225+
if (targetProtected) throw APIError.from('FORBIDDEN', cannotImpersonateAdmins);
226+
227+
// ── everything below is the vendor handler, unchanged ────────────────
228+
const session = await ctx.context.internalAdapter.createSession(
229+
targetUser.id,
230+
true,
231+
{
232+
impersonatedBy: callerId,
233+
expiresAt: new Date(Date.now() + IMPERSONATION_SESSION_SECONDS * 1000),
234+
},
235+
true,
236+
);
237+
if (!session) throw APIError.from('INTERNAL_SERVER_ERROR', failedToCreate);
238+
239+
const authCookies = ctx.context.authCookies;
240+
deleteSessionCookie(ctx);
241+
const dontRememberMeCookie = await ctx.getSignedCookie(
242+
authCookies.dontRememberToken.name,
243+
ctx.context.secret,
244+
);
245+
const adminCookieProp = ctx.context.createAuthCookie(ADMIN_SESSION_COOKIE_KEY);
246+
await ctx.setSignedCookie(
247+
adminCookieProp.name,
248+
`${ctx.context.session.session.token}:${dontRememberMeCookie || ''}`,
249+
ctx.context.secret,
250+
authCookies.sessionToken.attributes,
251+
);
252+
await setSessionCookie(ctx, { session, user: targetUser }, true);
253+
254+
return ctx.json({
255+
session,
256+
user: parseUserOutput(ctx.context.options, targetUser),
257+
});
258+
},
259+
);
260+
261+
return true;
262+
}

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import {
3636
rotateCallerBearerOnImpersonation,
3737
withBearerAdminSessionRecovery,
3838
} from './impersonation-bearer-rotation.js';
39+
import {
40+
applyPlatformAdminImpersonation,
41+
} from './admin-impersonate-endpoint.js';
3942
import {
4043
invitationRoleCapFailure,
4144
isPlainMemberInvitation,
@@ -2589,9 +2592,38 @@ export class AuthManager {
25892592
// match ObjectStack's snake_case conventions (ban_reason,
25902593
// ban_expires, impersonated_by). `role` and `banned` are already
25912594
// snake_case-compatible.
2592-
return admin({
2595+
const adminPlugin: any = admin({
25932596
schema: buildAdminPluginSchema(),
25942597
});
2598+
2599+
// ADR-0068 D2 — re-authorize `/admin/impersonate-user` on ObjectStack's
2600+
// platform-admin predicate, IN PLACE on this plugin's own endpoints
2601+
// record. See `admin-impersonate-endpoint.ts` for the measurement that
2602+
// picked this shape over a second plugin (which boots and serves, but
2603+
// makes `checkEndpointConflicts` log an error on every start) and over
2604+
// a raw Hono mount (forbidden: hand-rolled signed cookies, and it would
2605+
// silently detach the #8243 rotation hook keyed on this path).
2606+
//
2607+
// Every OTHER better-auth-native `/admin/*` route still gates on the
2608+
// legacy scalar and still refuses platform admins — that is the parent
2609+
// card's remaining surface, deliberately untouched here.
2610+
const rewired = await applyPlatformAdminImpersonation(
2611+
adminPlugin,
2612+
(userId: string) => this.isPlatformAdminUserId(userId),
2613+
);
2614+
if (!rewired) {
2615+
// The vendor renamed or dropped the endpoint. Say so loudly: the
2616+
// route then falls back to the vendor's own handler, which refuses
2617+
// every platform admin — a broken button, not an open door.
2618+
console.error(
2619+
'[AuthManager] better-auth\'s admin plugin no longer exposes a ' +
2620+
'`impersonateUser` endpoint at /admin/impersonate-user, so the ' +
2621+
'ADR-0068 platform-admin authorization could NOT be applied. ' +
2622+
'Impersonation will refuse every ObjectStack platform admin until ' +
2623+
'admin-impersonate-endpoint.ts is updated for the new vendor shape.',
2624+
);
2625+
}
2626+
return adminPlugin;
25952627
});
25962628
}
25972629

@@ -4319,6 +4351,47 @@ export class AuthManager {
43194351
return null;
43204352
}
43214353

4354+
/**
4355+
* ADR-0068 D2, asked on its own: is `userId` a PLATFORM admin — a
4356+
* `sys_user_permission_set` row pointing at the `admin_full_access`
4357+
* permission set with `organization_id = null` (seeded by
4358+
* `bootstrapPlatformAdmin`)?
4359+
*
4360+
* Deliberately NARROWER than {@link isOrgOrPlatformAdmin}: it does not admit
4361+
* organization owners/admins. Platform-admin routes must not be reachable by
4362+
* whoever happens to own an org (ADR-0068), so the two questions stay two
4363+
* methods.
4364+
*
4365+
* Reads through `withSystemReadContext` so the lookups are not themselves
4366+
* RLS-scoped to the acting — possibly non-privileged — user, and fails CLOSED
4367+
* (returns false) on any lookup error: this backs a security gate, and an
4368+
* unverifiable actor must never pass.
4369+
*/
4370+
private async isPlatformAdminUserId(userId: string): Promise<boolean> {
4371+
if (!userId) return false;
4372+
const engine = this.getDataEngine();
4373+
if (!engine) return false;
4374+
try {
4375+
const sys = withSystemReadContext(engine);
4376+
const links = await sys.find('sys_user_permission_set', {
4377+
where: { user_id: userId },
4378+
limit: 50,
4379+
});
4380+
const platformLinks = (Array.isArray(links) ? links : []).filter(
4381+
(l: any) => !l.organization_id,
4382+
);
4383+
if (platformLinks.length === 0) return false;
4384+
const sets = await sys.find('sys_permission_set', { limit: 50 });
4385+
const adminSet = (Array.isArray(sets) ? sets : []).find(
4386+
(r: any) => r.name === 'admin_full_access',
4387+
);
4388+
if (!adminSet) return false;
4389+
return platformLinks.some((l: any) => l.permission_set_id === adminSet.id);
4390+
} catch {
4391+
return false;
4392+
}
4393+
}
4394+
43224395
/**
43234396
* True when `userId` is a platform admin (a `sys_user_permission_set` row
43244397
* pointing at `admin_full_access` with `organization_id = null`) OR an

0 commit comments

Comments
 (0)