Repository navigation
Commit 0be162c
fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a held caller (ADR-0069 D3) (#23521)
Fixes #23335
Clause-②: yes (narrowing)
## What changes
ADR-0069 D3 gates a held session (an expired or administrator-forced
password, enforced MFA not yet enrolled) out of every protected resource
until it remediates. REST, the runtime dispatcher and the platform-admin
raw mounts (`judgePlatformAdmin`, PR #23360) already refuse one. The
doors better-auth serves itself never did: their session is a raw
better-auth read that `customSession` never decorates, and no ADR-0069
transport seam sees a request under the auth namespace. This PR closes
the card's remaining leg.
Each door now asks the session gate's own posture carrier for the acting
user, in the organization their session operates in:
`AuthManager.computeAuthGate`, the first entry of
`IAuthService.resolveUserAuthGates` (#23287's one per-user posture
computation, PR #23440). It then refuses a held caller with
`judgeHeldSession`, the held refusal `judgePlatformAdmin` already gives
(factored out of it, byte-identical). No new gate, no second posture
computation, no new error code.
### Doors and verdicts (security card)
| door | served by | a held caller before (measured, every held state) |
after |
|---|---|---|---|
| `POST /admin/impersonate-user` | better-auth endpoint rebuilt by
`applyPlatformAdminImpersonation` | platform admin: **200**, a session
minted as the target, and that session is not held, so the hold was
bypassed outright | **403** with the posture code, no session minted |
| `POST /sso/register` | `@better-auth/sso`, authorized by the global
before-hook | platform admin: **200**, the provider row landed | **403**
with the posture code, no row |
| `POST /sso/update-provider`, `POST /sso/delete-provider` |
`@better-auth/sso` (provider owner) | owning platform admin: **200**,
issuer rewritten to another host / row deleted | **403** with the
posture code, provider unchanged |
| `set-role`, `get-user`, `update-user`, `list-users`,
`list-user-sessions`, `revoke-user-session`, `revoke-user-sessions` (all
under `/admin/`) | better-auth `admin` plugin, authorized on the legacy
`role` scalar | caller carrying `role = 'admin'`: **200** on every one,
`update-user` landed | **403** with the posture code, the write never
lands |
The refusal, measured on the boot: `403
{"success":false,"error":{"code":"MFA_REQUIRED","message":"Multi-factor
authentication is required. Please set up an authenticator app to
continue."}}`, the same `code` and `message` (the remediation pointer)
the session gate gives on a data read and the same body
`judgePlatformAdmin` gives on the raw platform-admin mounts.
### Posture to verdict (H1, measured)
| posture held | scope (ruling C′ item 1) | session gate on a
non-allow-listed route (data read) | these doors |
|---|---|---|---|
| `PASSWORD_EXPIRED` | credential | 403 `PASSWORD_EXPIRED` | 403
`PASSWORD_EXPIRED` |
| `PASSWORD_RESET_REQUIRED` | credential | 403 `PASSWORD_RESET_REQUIRED`
| 403 `PASSWORD_RESET_REQUIRED` |
| `MFA_REQUIRED` (global floor) | account | 403 `MFA_REQUIRED` | 403
`MFA_REQUIRED` |
| `MFA_REQUIRED` (only the session's organization requires it) | account
| 403 `MFA_REQUIRED` | 403 `MFA_REQUIRED` |
| none, or the posture lookup failed | none | 200 | admitted, unchanged
|
Source of that verdict: the session gate holds every scope.
`computeAuthGate` puts the first entry of `resolveUserAuthGates` on
`user.authGate` ("Every scope holds a session, because a session IS the
password sign-in"), and `evaluateAuthGate` refuses whatever posture that
carrier holds, without reading its scope. The API-key seam's
`account`-only rule is not reused here: a key is not a session, and
these doors are session doors.
### What an attacker who holds only a held administrator's password can
no longer reach
- Mint a session as any non-admin user (impersonation), which previously
bypassed the hold outright because the minted session is not held.
- Register an identity provider, or rewrite the issuer and endpoints of
one the administrator owns, or delete it: login routing for the
environment.
- Through a legacy `role = 'admin'` scalar: list and read users, list
and revoke their sessions, update users, set roles.
Still reachable for a held caller, on purpose: sign-in, change-password,
two-factor enrollment, sign-out (remediation, unchanged);
`/admin/stop-impersonating` (the exit from an impersonation, carried by
the impersonated session); `/admin/has-permission` (a question that
refuses no one).
## How each door asks (H2, H3, H5)
- **H2, where the posture comes from (measured).** With a probe line in
the impersonation handler (built into dist, then removed), the caller
`adminMiddleware` hands the endpoint carried no `authGate` in 15 of 15
calls (five states, three actors), and no `isPlatformAdmin` either: it
is the raw `sys_user` row. `GET /auth/get-session` on the same session
did carry `authGate`. So every door asks; none reads the request.
- **The ask.** Impersonation: an injected `SessionPostureOracle`, wired
in `auth-manager.ts` to `computeAuthGate(userId, organizationId)`, asked
at the start of the handler with
`ctx.context.session.session.activeOrganizationId`. SSO and the
role-scalar actions: the global before-hook resolves the actor
(`resolveActor`, user id and active organization) and calls
`assertActorNotHeld`, which asks the same `computeAuthGate`. The
role-scalar and SSO update/delete branches skip the session read when no
gate feature is active (`isAuthGateActive`, the same caches the
computation reads), as the transport seams do.
- **H3, the refusal.** `judgeHeldSession(sessionUser)`
(`platform-admin-gate.ts`) is `evaluateAuthGate(sessionUser, '')` plus
the judge's envelope; `judgePlatformAdmin` now calls it. Inside
better-auth it is thrown as `APIError('FORBIDDEN', record)` with that
record, so the vendor lane answers the gate's nested envelope, not its
own flat one. The codes are the closed `AuthGateCodeSchema` vocabulary.
- **Order.** Identity (the vendor's 401) first, then the posture, then
authorization, as the session gate and the judge apply it. A held
signed-in caller who is not an administrator now reads the posture code
instead of the vendor's `403 YOU_ARE_NOT_ALLOWED_*` (same status).
Pinned at the impersonation door.
- **H5, organization.** The organization passed is the one the session
operates in. Pinned: with only the organization's `require_mfa` set,
`resolveUserAuthGates(admin, { organizationId })` answers
`[MFA_REQUIRED]` and `resolveUserAuthGates(admin, {})` answers `[]`, and
impersonation, registration and a role-scalar write are refused
`MFA_REQUIRED`.
- **Fail-open.** A posture read that fails answers `[]` (the
computation's own contract), so the door admits, exactly as the session
gate does. Pinned on the boot with the posture read failing.
## Measured first (H4: the role-scalar vendor actions)
Real boot (`@objectstack/verify` `bootStack`, `OS_SCIM_ENABLED` for the
`admin` plugin, `OS_SSO_ENABLED`), before the fix (plugin-auth built
from `f9b421716`), a signed-up member carrying `sys_user.role =
'admin'`:
| action | unheld | PASSWORD_EXPIRED | MFA_REQUIRED |
PASSWORD_RESET_REQUIRED | MFA by organization |
|---|---|---|---|---|---|
| `list-users`, `get-user`, `list-user-sessions` | 200 | 200 | 200 | 200
| 200 |
| `update-user` (the name changed) | 200 | 200 | 200 | 200 | 200 |
| `set-role`, `revoke-user-sessions`, `revoke-user-session` | 200 | 200
| 200 | 200 | 200 |
| protected data read (control) | 200 | 403 | 403 | 403 | 403 |
The ADR-0068 platform admin (no scalar) is refused there by the vendor
with `403 YOU_ARE_NOT_ALLOWED_*`, held or not. So the held-caller check
applies to these seven actions, as the dispatch's surface rule names.
`VENDOR_ROLE_SCALAR_ADMIN_PATHS` (`admin-impersonate-endpoint.ts`) holds
them, and a unit pin classifies every route the installed `admin` plugin
registers (held here; impersonation's own door; a raw mount that runs
`judgePlatformAdmin` first; the exit; the question), so a vendor route
nobody decided for turns red.
## Tests
Final head `14edf8922` unless stated.
- New booted pin,
`packages/verify/src/held-session-vendor-admin-doors.pin.test.ts`, 28
cases:
- unheld controls: impersonation 200 with one session minted,
registration 200 with the row landed, owner issuer rewrite 200 with the
row changed, scalar `update-user` 200 with the name changed;
- per held state (`PASSWORD_EXPIRED`, `MFA_REQUIRED`,
`PASSWORD_RESET_REQUIRED`), six cases each: impersonation 403 with the
code and no session minted; registration 403 and no row; owner rewrite
and delete 403 and the provider unchanged; scalar `update-user` 403 and
the name unchanged; all seven role-scalar actions 403 with the code; a
held non-admin at impersonation reads the posture code;
- organization-only MFA: the computation reads `[MFA_REQUIRED]` with the
organization and `[]` without; impersonation, registration and scalar
`update-user` refused;
- fail-open: with the posture read failing, the computation answers
`[]`, the data read is 200 and impersonation is 200.
- With the existing `held-session-platform-admin.pin.test.ts`: `Test
Files 2 passed (2) · Tests 41 passed (41)`.
- New unit pin, `admin-impersonate-endpoint.held-caller.test.ts` (4
cases: every installed `admin` plugin path classified exactly once, no
stale entry, the held set, every mount-judged path really opens with
`gateAdmin`). `platform-admin-gate.held-session.test.ts` gains 5 cases
for `judgeHeldSession`, including the byte-identity with the judge.
Targeted plugin-auth run (9 files, `auth-manager.test.ts` included):
`Tests 426 passed (426)`.
- **Ablation, the impersonation door's check**
(`scripts/ablation-replace.mjs`, anchor 1 to 0, blob `4e2afe618` to
`67beda690`; plugin-auth rebuilt; `ablation-dist-preflight` marker
present in `dist/index.js` and `dist/index.mjs`): the booted pin went `7
failed | 21 passed (28)`, exactly the seven impersonation cases (three
held states, the three held non-admin cases, the organization case).
Registration, the SSO owner doors, the role-scalar actions, the controls
and fail-open stayed green. Restore: blob equals HEAD `4e2afe618`, `git
diff HEAD` empty, plugin-auth rebuilt, preflight `--absent` green with
the whole tree clean, pins `41 passed (41)`.
- `@objectstack/plugin-auth` full suite at `beebec8f3` (the head before
a refactor that only binds the refusal record to a local name), 4
shards: 36 + 35 + 35 + 35 files, 779 + 928 (10 skipped) + 520 + 597 =
2824 passed.
- `@objectstack/verify` full suite at `beebec8f3`, 3 shards: 40 files,
107 + 126 + 114 = 347 passed.
- Typecheck (`tsc --noEmit` plus `check:test-typecheck`): plugin-auth
exit 0 at `14edf8922`; verify exit 0 at `beebec8f3`.
- eslint `--no-inline-config` on the 6 changed `.ts` files: 0 errors, 0
warnings. The repo-wide lint run is CI's.
## Gates
- Derived at `14edf8922`: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` names 68 families for this diff (the
PM's lead listed 50 at `ad5a7d7c3`).
- All 68 run locally at `14edf8922`, each exit code captured before any
pipe: 68 of 68 exit 0. The two dist-reading families first needed state:
`check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (exit 3,
missing dist) until a full workspace build (turbo, 76 tasks, exit 0);
`check:dispatcher-error-vocabulary` found the `held.body` record (see
Acceptance notes), fixed in `14edf8922`.
- Reconciliation: `dispatch-gates.mjs --ran` reports `68 derived, 68
run, 0 NOT-MEASURED, 0 UNRUN`.
- Changeset gates: `check:adr-0087-registration` reads `not-required
(no-migration-prescription)` on
`.changeset/23335-held-admin-vendor-doors-refused.md` (BREAKING, bang,
clause-② narrowing); `check:changeset-no-major` and
`check:empty-changeset` exit 0.
- Families CI owns (lint, the artifact roster, the type-check lanes) are
read from this PR's check-runs, not re-run here.
## Acceptance notes
- **In-place fix under the bounded exemption: SSO `update-provider` and
`delete-provider`.** Measured on the boot at `ad8dee166` (registration
already fixed there): a held (`MFA_REQUIRED`) platform admin who owned a
provider rewrote its issuer to another host (200, landed) and deleted
another (200, row gone). Same defect class as the registration door,
fixed with the same mechanical branch in the same file, pinned in the
same file and covered by the same gate families. The claim's file
surface named the registration hook only; the seat may record the
widening.
- `check:dispatcher-error-vocabulary` first read `APIError('FORBIDDEN',
held.body)` as a member of an imported code table and reported it
unresolved. The record is the judge's own object literal, whose `code`
comes from the closed posture vocabulary, so it is now bound to a local
name and handed over (`14edf8922`, no behaviour change).
- **New export:** `judgeHeldSession` from `@objectstack/plugin-auth`,
via the package's `export *` of `platform-admin-gate.ts`. No new error
code.
- Not changed, and reads only: a held provider owner still gets `GET
/sso/get-provider` and `GET /sso/providers` (sanitized provider details;
measured 200). The vendor's `/sso/request-domain-verification` and
`/sso/verify-domain` were not measured (domain verification is off in
this boot); their `/admin/sso/*` bridges are judged.
- `/admin/oauth2/*` (the oauth-provider plugin) is `SERVER_ONLY` in the
installed 1.7.3 (read from source), so it is no HTTP door.
- The booted pins live in `packages/verify`, beside #23360's, because
plugin-auth cannot depend on verify. The organization-only case primes
`_orgMfaCache` as the background refresh would, the precedent
`auth-manager.test.ts` uses.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 869a381 commit 0be162c
7 files changed
Lines changed: 797 additions & 13 deletions
File tree
- .changeset
- packages
- plugins/plugin-auth/src
- verify/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
105 | 134 | | |
106 | 135 | | |
107 | | - | |
| 136 | + | |
108 | 137 | | |
109 | 138 | | |
110 | 139 | | |
| |||
118 | 147 | | |
119 | 148 | | |
120 | 149 | | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
121 | 201 | | |
122 | 202 | | |
123 | 203 | | |
| |||
166 | 246 | | |
167 | 247 | | |
168 | 248 | | |
| 249 | + | |
169 | 250 | | |
170 | 251 | | |
171 | 252 | | |
| |||
202 | 283 | | |
203 | 284 | | |
204 | 285 | | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
205 | 302 | | |
206 | 303 | | |
207 | 304 | | |
| |||
0 commit comments