Skip to content

Commit c2d4072

Browse files
fix(plugin-auth): a credential-scope posture does not hold an impersonation session (#24762) (#24866)
Fixes #24762 Clause-②: no An administrator who impersonated a user who must change their password (the default of Create User, Set Password and Import) was stranded: the impersonation session was refused `403 PASSWORD_RESET_REQUIRED` on every protected route, and the console showed the target's change-password screen. This PR executes B1 of the ruling, items 1 to 5. Item 6 (C, the console's "Stop impersonating" exit) is objectstack-ai/objectui#13208, a separate card that stays open and is related only. ## The ruling this executes Ruling `6288774214` (`batch #376` item 1, letter E, maintainer 「同意」 2026-10-20T10:23Z), quoted: > 1. **The rule.** A `credential`-scope posture (`PASSWORD_RESET_REQUIRED`, `PASSWORD_EXPIRED`) does not hold an impersonation session. An `account`-scope posture (`MFA_REQUIRED`, and any future account-standing posture) still does. Why: the impersonation session is minted from the administrator's own session, which has passed its own posture check. It is not the target's password sign-in, and the password sign-in is the only thing a `credential` posture holds, per the declaration. > 2. **Where: once, at the producer.** In `plugin-auth`'s `customSession`, when `session.impersonatedBy` is set, filter the postures `resolveUserAuthGates` reports to those whose `AUTH_GATE_SCOPES[code]` is `account`, then take the first that remains. ⛔ Do not drop `gates[0]`: a target held by both `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED` answers `MFA_REQUIRED`. No seam, door or client restates the rule. `Governing text:` ADR-0069 D3 and its amended note (ruling C′ on #23287, 6159450584); the ADR-0069 status block; `AUTH_GATE_SCOPES` in `packages/spec/src/kernel/execution-context.zod.ts` (read, not edited). ## What changed - `packages/plugins/plugin-auth/src/auth-manager.ts` - `customSession` reads `session.impersonatedBy` (the print door's reading: a non-blank string) and passes `{ impersonation: true }` to `computeAuthGate`. - `computeAuthGate` takes an optional `options` argument. With `impersonation: true` it answers `accountScopeAuthGate(gates)`, the first `account`-scope posture wherever it sits in the list. That is `@objectstack/core`'s existing reader over `AUTH_GATE_SCOPES`, the one the API-key admission seam already uses, so no second list exists. Without the option it answers `gates[0]`, byte for byte as before. - The docblock premise "Every scope holds a session, because a session IS the password sign-in" is corrected to name the impersonation session (ruling item 4). The `resolveUserAuthGates` docblock's two sentences about what the session shows are corrected with it. - `content/docs/permissions/authentication.mdx`: the `mustChangePassword` passage gains the impersonation sentence, and says that enforced MFA still holds an impersonation session. - `.changeset/24762-impersonation-credential-posture.md`: `@objectstack/plugin-auth` patch, `Clause-②: no`. - Pins, below. No ADR, no spec edit, no new error code and no new gate (ruling item 5). ### How the impersonation fact reaches `computeAuthGate` (the choice the ruling left open) Measured first (H3): on a real boot, `GET /auth/get-session` with the impersonation session's bearer answers `session.impersonatedBy` equal to the administrator's id, and `customSession` receives the same `session` object. The pin's `impersonateTarget()` asserts this on every impersonation it uses. - **Chosen: a parameter on `computeAuthGate`** (`options.impersonation`, default absent). The session gate keeps ONE carrier, and the rule lives in it. - **Not chosen: a filter at the call** (`customSession` calling `resolveUserAuthGates` and filtering inline). The session posture would then be derived in two places, and `computeAuthGate` would no longer be the session gate's carrier that the doors cite. - Four axes: business need, the same (both serve the measured verify-a-new-user path). Long-term soundness: the parameter keeps one derivation. AI error resistance: the parameter is opt-in and named, so a caller that omits it gets the old answer, and no second copy of the filter can drift. Startup scope: both are the same size; neither adds a gate. ### Other callers of `computeAuthGate`: unchanged for every session - The impersonation door's caller check (`applyPlatformAdminImpersonation`'s `postureOf`) and the vendor-served platform-admin doors (`assertActorNotHeld`: the admin plugin's role-scalar actions and `/sso/register|update-provider|delete-provider`) pass no option. Their answer is `gates[0]`, as before, for a non-impersonation session and for an impersonation session alike. - Evidence: the existing `computeAuthGate` unit cases (options omitted) pass unchanged, and `@objectstack/verify`'s `held-session-vendor-admin-doors.pin.test.ts` (both callers on a real boot, every held state) passes: 3 files, 49 tests, below. ## Pins (ruling item 3), each ablated `packages/verify/src/held-session-impersonation-target.pin.test.ts`, a real boot through the data plane's seam, each refusal asserted as `403` with the posture's code nested under `error` (ADR-0112): - (a) a `PASSWORD_RESET_REQUIRED` target's impersonation session reads `GET /data/sys_user?limit=1` 200, and its session payload carries no `authGate`. The reset is armed through the real door, `/admin/set-user-password` with its `mustChangePassword` default. - (b) the target's own session still answers `403 PASSWORD_RESET_REQUIRED`. - (c) an `MFA_REQUIRED` target's impersonation session still answers `403 MFA_REQUIRED`. - (d) a target held by both answers `403 MFA_REQUIRED` on the impersonation session. - Controls: an unheld target reads 200 on both sessions; the MFA hold and the compound hold are proved armed on the target's own session. Ruling item 7: the forced reset's revocation (on by default) still ends the impersonation session (`revoked.sessions` at least 1, then the same bearer answers 401). `packages/plugins/plugin-auth/src/auth-manager.test.ts`, the same four cases at the producer (`customSession`'s stamped `user.authGate`), plus `PASSWORD_EXPIRED`, the other credential-scope posture. **Ablation**, one leg removing the filter. The line `if (options.impersonation === true) return accountScopeAuthGate(gates) ?? undefined;` was replaced with a planted no-op that keeps the declaration build clean (`void ['ablation-24762-filter-removed', accountScopeAuthGate, options];`), through `scripts/ablation-replace.mjs`: anchor x1 to x0, blob `45d22147db17` to `3c94be9c4c2f`. The plugin-auth build answered 0. `ablation-dist-preflight.mjs` found the plant in `dist/index.js` and `dist/index.mjs`, and the filter's text absent from `dist/`. - verify pin, mutated: **3 failed, 5 passed**. (a) red, `expected 403 to be 200`; (d) red, `expected 'PASSWORD_RESET_REQUIRED' to be 'MFA_REQUIRED'`; the revocation control red at its first read, `expected 403 to be 200` (it reads the impersonation session before revoking). (b), (c), the unheld control and both armed-hold controls stayed green. - plugin-auth pins, mutated: **3 failed, 1 passed**. Reset, expiry and both turned red; the MFA case stayed green. - Restore: `ablation-replace.mjs --restore`, blob after restore equal to the HEAD blob `45d22147db17`, `git diff HEAD` empty, `git status --porcelain` empty. Rebuilt; the plant absent from `dist/`, the filter back in it. Re-run: verify 8 passed, plugin-auth 4 passed. - A first leg deleted the line outright. Its JS build landed and its readings were the same, but the declaration build refused the then-unused import and parameter (TS6133), so the leg above replaced it for a clean build record. Nothing from the first leg is relied on. ## Local verification All on `c62a583b73` (this branch's head, `origin/main` `950f949a9c` merged in), each command under `scripts/pm/os-verify-lock.sh`, each exit captured before any pipe: - `pnpm exec turbo run build --filter='@objectstack/verify^...' --concurrency=2`: exit 0, 48 of 48 tasks. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: exit 0, 170 files, 3264 passed, 10 skipped. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. - `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2` on `held-session-impersonation-target.pin.test.ts`, `held-session-vendor-admin-doors.pin.test.ts` and `held-session-platform-admin.pin.test.ts`: exit 0, 3 files, 49 passed. The rest of the verify suite is a declared narrowing, left to CI's Test Core. - `pnpm --filter @objectstack/verify typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths): 100 commands, each run. `--ran`: 100 derived, 99 run, 1 NOT MEASURED, 0 unrun. 98 answered exit 0 on the first pass. `check:skill-examples` answered exit 3 (PREREQUISITE NOT MET: no client SDK build); after building `@objectstack/client` and `@objectstack/client-react` it answered exit 0. NOT MEASURED: `check:dual-build-cjs-loads`, reason: exit 3, PREREQUISITE NOT MET, 21 packages outside this diff's build closure have no `dist/`. CI's Lint & Repo Gates runs it. A direct probe: `require('packages/plugins/plugin-auth/dist/index.js')` loads (exit 0, 155 exports). ## Acceptance notes - **Pin location.** The claim placed the pins in `plugin-auth`. No `plugin-auth` harness serves a protected route through a transport seam, and the ADR-0112 envelope is the seam's, so the route-level pins are in `@objectstack/verify`, where the real-boot held-session pins already live. The producer-level cases are in `plugin-auth`. - **Spec text now imprecise for impersonation sessions (not edited: the ruling owes no spec edit).** `packages/spec/src/contracts/auth-service.ts`, `IAuthService.resolveUserAuthGates`, says "the session gate carries its first entry on `user.authGate`". On an impersonation session it carries the first `account`-scope entry. Suggested wording for a later spec touch: "the session gate carries its first entry on `user.authGate` (on an impersonation session, its first `account`-scope entry)". No carrier. - **`@objectstack/core`'s `accountScopeAuthGate` docblock** names only the API-key reading; it now also serves the impersonation session. Not edited (out of the claim's file surface). No carrier. - **The asking doors still read the target's full posture on an impersonation session.** `assertActorNotHeld` and the impersonation door's caller check ask `computeAuthGate` with the option omitted (the hard stop kept their answers unchanged). So an impersonation session of a credential-held target that reaches one of those platform-admin doors is refused `403` with the credential code, where an unheld target's impersonation session is refused by the door's own platform-admin check (also `403`). No request they refuse is admitted. Recorded as an open question in the report. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b167644 commit c2d4072

5 files changed

Lines changed: 360 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): an administrator's impersonation session is not held by the target's forced password reset or password expiry (#24762)
6+
7+
Clause-②: no
8+
9+
Impersonating a user who must change their password used to strand the administrator: the impersonation session was refused `403 PASSWORD_RESET_REQUIRED` on every protected route, and the console showed the target's change-password screen. An impersonation session is minted from the administrator's own session, which has passed its own posture check, so it is not the user's password sign-in, and a `credential`-scope posture holds the password sign-in alone (`AUTH_GATE_SCOPES`).
10+
11+
- **What moves.** On a session that carries `impersonatedBy`, the session gate (`user.authGate`, stamped in `customSession`) now shows only the first `account`-scope posture the user holds. `PASSWORD_RESET_REQUIRED` and `PASSWORD_EXPIRED` no longer hold it. `MFA_REQUIRED` still does, so an impersonation session of a user who owes MFA enrolment is still refused `403 MFA_REQUIRED`, including when the user also owes a password change.
12+
- **Unchanged.** The user's own sign-in stays held until they change the password. The forced reset's revocation (`revokeSessionsAndKeys`, on by default) still ends every session of the user, impersonation sessions included. API keys, the print door's refusal of an impersonation session, and the impersonation door's own check of the administrator's posture are unchanged.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,12 @@ logged. While `mustChangePassword` is set, the user is blocked with
981981
`403 PASSWORD_RESET_REQUIRED` on every protected route until they complete a
982982
password change. It is the same gate as password expiry, under its own code,
983983
and like expiry it holds password sign-in only: the user's API keys are not
984-
held by it.
984+
held by it. Nor is an administrator's impersonation session, which is minted
985+
from the administrator's own session and is not the user's password sign-in:
986+
an administrator can impersonate the user to verify their access before the
987+
user has changed the password, while the user's own sign-in stays blocked.
988+
Enforced MFA (`MFA_REQUIRED`) holds the account, so it still holds an
989+
impersonation session of that user.
985990

986991
#### Set / Reset a Password
987992

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3665,7 +3665,7 @@ describe('AuthManager', () => {
36653665
findOne: vi.fn(),
36663666
}, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] });
36673667

3668-
const getSessionCallback = async (dataEngine: any) => {
3668+
const getSessionCallback = async (dataEngine: any, extra: Record<string, unknown> = {}) => {
36693669
let capturedConfig: any;
36703670
(betterAuth as any).mockImplementation((config: any) => {
36713671
capturedConfig = config;
@@ -3677,6 +3677,7 @@ describe('AuthManager', () => {
36773677
secret: 'test-secret-at-least-32-chars-long',
36783678
baseUrl: 'http://localhost:3000',
36793679
dataEngine,
3680+
...extra,
36803681
});
36813682
await manager.getAuthInstance();
36823683
warnSpy.mockRestore();
@@ -3772,6 +3773,56 @@ describe('AuthManager', () => {
37723773
expect(result.user).toBe(user);
37733774
expect(result.user.positions).toBeUndefined();
37743775
});
3776+
3777+
// [#24762, ruling 6288774214 items 1–3] What `user.authGate` the producer
3778+
// stamps on an impersonation session (`session.impersonatedBy` set) of a
3779+
// held user: only an `account`-scope posture (`AUTH_GATE_SCOPES`) holds
3780+
// it, the first one the user's list reports. The user's own session keeps
3781+
// the list's first entry. The same cases on a real boot, through the
3782+
// transport seams' 403 envelope, are `@objectstack/verify`'s
3783+
// `held-session-impersonation-target.pin.test.ts`.
3784+
describe('the posture stamped on an impersonation session (ADR-0069, AUTH_GATE_SCOPES)', () => {
3785+
const LAPSED = () => new Date(Date.now() - 30 * 86_400_000).toISOString();
3786+
const IMPERSONATION = { impersonatedBy: 'u-admin' };
3787+
// Every posture computable at once: enforced MFA with no grace, and a
3788+
// one-day expiry. Each row below decides which postures its user holds.
3789+
const POLICY = { mfaRequired: true, mfaGracePeriodDays: 0, passwordExpiryDays: 1 };
3790+
const sessionGateFor = async (row: Record<string, unknown>, session: Record<string, unknown>) => {
3791+
const engine = makeDataEngine({ platformAdmin: false });
3792+
// The block's double, its `findOne` answering the posture read of the user row.
3793+
engine.findOne = vi.fn(async (object: string, q?: any) => {
3794+
assertEngineFindOnePredicate(object, q);
3795+
return object === 'sys_user' && q?.where?.id === 'u-1' ? { id: 'u-1', ...row } : null;
3796+
}) as any;
3797+
const callback = await getSessionCallback(engine, POLICY);
3798+
const result = await callback({ user: { id: 'u-1', email: 'a@b.com' }, session });
3799+
return result.user.authGate?.code;
3800+
};
3801+
const RESET = { must_change_password: true, two_factor_enabled: true, password_changed_at: new Date().toISOString() };
3802+
const EXPIRED = { must_change_password: false, two_factor_enabled: true, password_changed_at: LAPSED() };
3803+
const MFA = { must_change_password: false, two_factor_enabled: false, mfa_required_at: LAPSED(), password_changed_at: new Date().toISOString() };
3804+
const RESET_AND_MFA = { ...MFA, must_change_password: true };
3805+
3806+
it('a forced-reset user: their own session is held, an impersonation session of them is not', async () => {
3807+
expect(await sessionGateFor(RESET, {})).toBe('PASSWORD_RESET_REQUIRED');
3808+
expect(await sessionGateFor(RESET, IMPERSONATION)).toBeUndefined();
3809+
});
3810+
3811+
it('an expired-password user: the same, the other credential-scope posture', async () => {
3812+
expect(await sessionGateFor(EXPIRED, {})).toBe('PASSWORD_EXPIRED');
3813+
expect(await sessionGateFor(EXPIRED, IMPERSONATION)).toBeUndefined();
3814+
});
3815+
3816+
it('an MFA-held user: the account-scope posture holds the impersonation session too', async () => {
3817+
expect(await sessionGateFor(MFA, {})).toBe('MFA_REQUIRED');
3818+
expect(await sessionGateFor(MFA, IMPERSONATION)).toBe('MFA_REQUIRED');
3819+
});
3820+
3821+
it('a user held by both: their own session shows the reset, the impersonation session MFA_REQUIRED', async () => {
3822+
expect(await sessionGateFor(RESET_AND_MFA, {})).toBe('PASSWORD_RESET_REQUIRED');
3823+
expect(await sessionGateFor(RESET_AND_MFA, IMPERSONATION)).toBe('MFA_REQUIRED');
3824+
});
3825+
});
37753826
});
37763827

37773828
// ADR-0069 D1: breached-password rejection enables better-auth's native

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

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
API_KEY_DEFAULT_EXPIRY_DAYS,
4949
API_KEY_INACTIVITY_DAYS,
5050
API_KEY_MAX_EXPIRY_DAYS,
51+
accountScopeAuthGate,
5152
hasPlatformAdminStanding,
5253
resolveUserAuthzGrants,
5354
} from '@objectstack/core';
@@ -4593,10 +4594,16 @@ export class AuthManager {
45934594
// revokes the session in place when exceeded (next request → 401).
45944595
await this.enforceSessionControls((session as any)?.id, (session as any)?.createdAt);
45954596

4597+
// An impersonation session carries the administrator who started it
4598+
// (`impersonatedBy`, written by the admin plugin's impersonation and
4599+
// stored as `sys_session.impersonated_by`); which postures hold the
4600+
// session depends on it — see computeAuthGate().
4601+
const impersonatedBy = (session as any)?.impersonatedBy;
45964602
const authGate = await this.computeAuthGate(
45974603
user.id,
45984604
(session as any)?.activeOrganizationId,
45994605
(user as any)?.twoFactorEnabled === true,
4606+
{ impersonation: typeof impersonatedBy === 'string' && impersonatedBy.trim() !== '' },
46004607
);
46014608

46024609
return {
@@ -8897,15 +8904,36 @@ export class AuthManager {
88978904
/**
88988905
* ADR-0069 — the auth-policy gate posture for a SESSION: the first, most
88998906
* urgent posture {@link resolveUserAuthGates} reports for its user in its
8900-
* active organization, else undefined. Every scope holds a session, because
8901-
* a session IS the password sign-in (#23287, ruling C′ item 1).
8907+
* active organization that holds this session, else undefined.
8908+
*
8909+
* What a posture holds is its declared scope (`AUTH_GATE_SCOPES`, #23287,
8910+
* ruling C′ item 1): `account` holds every credential of the user,
8911+
* `credential` holds the password sign-in alone. Which postures hold a
8912+
* session therefore depends on what the session is:
8913+
*
8914+
* - a session the user signed in to themselves is held by every scope: it
8915+
* IS the password sign-in a `credential` posture holds;
8916+
* - an IMPERSONATION session (`options.impersonation`, read off
8917+
* `session.impersonatedBy`) is not the user's password sign-in. It is
8918+
* minted from the administrator's own session, which passed its own
8919+
* posture check at the impersonation door. So only an `account`-scope
8920+
* posture holds it: the first one the list reports, wherever it sits
8921+
* (`accountScopeAuthGate`, the reading the API-key admission seam gives
8922+
* a key, the other credential that is not the password sign-in). A target
8923+
* held by both the forced reset and enforced MFA answers `MFA_REQUIRED`
8924+
* on it, never nothing (#24762, ruling 6288774214 items 1 and 2).
8925+
*
8926+
* The other callers ask for a user's own session (`options` omitted), so
8927+
* their answer is the list's first entry, as it always was.
89028928
*/
89038929
private async computeAuthGate(
89048930
userId: string,
89058931
activeOrgId: string | undefined,
89068932
_twoFactorEnabledHint: boolean,
8933+
options: { impersonation?: boolean } = {},
89078934
): Promise<AuthGate | undefined> {
89088935
const gates = await this.resolveUserAuthGates(userId, { organizationId: activeOrgId });
8936+
if (options.impersonation === true) return accountScopeAuthGate(gates) ?? undefined;
89098937
return gates[0];
89108938
}
89118939

@@ -8914,7 +8942,8 @@ export class AuthManager {
89148942
* `userId` holds right now, most urgent first; `[]` when no policy holds
89158943
* them. The ONE per-user posture computation (`IAuthService.resolveUserAuthGates`):
89168944
*
8917-
* - the session gate takes its first entry ({@link computeAuthGate}, onto
8945+
* - the session gate takes its first entry, and on an impersonation session
8946+
* its first `account`-scope entry ({@link computeAuthGate}, onto
89188947
* `user.authGate` in `customSession`);
89198948
* - the API-key admission seam (`resolveApiKeyAdmission`, `@objectstack/core`)
89208949
* asks it for a key's owner and holds the key on an `account`-scope entry
@@ -8925,10 +8954,11 @@ export class AuthManager {
89258954
* organization may require MFA above the global floor.
89268955
*
89278956
* Order, most urgent first: the administrator's forced reset, then natural
8928-
* password expiry, then enforced MFA. The session shows the first; a key is
8929-
* held only by an `account`-scope entry, wherever it sits in the list — a
8930-
* user whose password expired AND who owes MFA enrolment holds their keys
8931-
* on the MFA entry even though their session shows the expiry.
8957+
* password expiry, then enforced MFA. The user's own session shows the
8958+
* first; a key, and an impersonation session, are held only by an
8959+
* `account`-scope entry, wherever it sits in the list — a user whose
8960+
* password expired AND who owes MFA enrolment holds their keys on the MFA
8961+
* entry even though their session shows the expiry.
89328962
*
89338963
* No-op (no read) when no gate feature is enabled. Fails OPEN on any lookup
89348964
* error, answering `[]` — a transient hiccup must never lock a compliant user

0 commit comments

Comments
 (0)