Repository navigation
Commit c2d4072
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
- .changeset
- content/docs/permissions
- 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
981 | 981 | | |
982 | 982 | | |
983 | 983 | | |
984 | | - | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
985 | 990 | | |
986 | 991 | | |
987 | 992 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3665 | 3665 | | |
3666 | 3666 | | |
3667 | 3667 | | |
3668 | | - | |
| 3668 | + | |
3669 | 3669 | | |
3670 | 3670 | | |
3671 | 3671 | | |
| |||
3677 | 3677 | | |
3678 | 3678 | | |
3679 | 3679 | | |
| 3680 | + | |
3680 | 3681 | | |
3681 | 3682 | | |
3682 | 3683 | | |
| |||
3772 | 3773 | | |
3773 | 3774 | | |
3774 | 3775 | | |
| 3776 | + | |
| 3777 | + | |
| 3778 | + | |
| 3779 | + | |
| 3780 | + | |
| 3781 | + | |
| 3782 | + | |
| 3783 | + | |
| 3784 | + | |
| 3785 | + | |
| 3786 | + | |
| 3787 | + | |
| 3788 | + | |
| 3789 | + | |
| 3790 | + | |
| 3791 | + | |
| 3792 | + | |
| 3793 | + | |
| 3794 | + | |
| 3795 | + | |
| 3796 | + | |
| 3797 | + | |
| 3798 | + | |
| 3799 | + | |
| 3800 | + | |
| 3801 | + | |
| 3802 | + | |
| 3803 | + | |
| 3804 | + | |
| 3805 | + | |
| 3806 | + | |
| 3807 | + | |
| 3808 | + | |
| 3809 | + | |
| 3810 | + | |
| 3811 | + | |
| 3812 | + | |
| 3813 | + | |
| 3814 | + | |
| 3815 | + | |
| 3816 | + | |
| 3817 | + | |
| 3818 | + | |
| 3819 | + | |
| 3820 | + | |
| 3821 | + | |
| 3822 | + | |
| 3823 | + | |
| 3824 | + | |
| 3825 | + | |
3775 | 3826 | | |
3776 | 3827 | | |
3777 | 3828 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| 51 | + | |
51 | 52 | | |
52 | 53 | | |
53 | 54 | | |
| |||
4593 | 4594 | | |
4594 | 4595 | | |
4595 | 4596 | | |
| 4597 | + | |
| 4598 | + | |
| 4599 | + | |
| 4600 | + | |
| 4601 | + | |
4596 | 4602 | | |
4597 | 4603 | | |
4598 | 4604 | | |
4599 | 4605 | | |
| 4606 | + | |
4600 | 4607 | | |
4601 | 4608 | | |
4602 | 4609 | | |
| |||
8897 | 8904 | | |
8898 | 8905 | | |
8899 | 8906 | | |
8900 | | - | |
8901 | | - | |
| 8907 | + | |
| 8908 | + | |
| 8909 | + | |
| 8910 | + | |
| 8911 | + | |
| 8912 | + | |
| 8913 | + | |
| 8914 | + | |
| 8915 | + | |
| 8916 | + | |
| 8917 | + | |
| 8918 | + | |
| 8919 | + | |
| 8920 | + | |
| 8921 | + | |
| 8922 | + | |
| 8923 | + | |
| 8924 | + | |
| 8925 | + | |
| 8926 | + | |
| 8927 | + | |
8902 | 8928 | | |
8903 | 8929 | | |
8904 | 8930 | | |
8905 | 8931 | | |
8906 | 8932 | | |
| 8933 | + | |
8907 | 8934 | | |
8908 | 8935 | | |
| 8936 | + | |
8909 | 8937 | | |
8910 | 8938 | | |
8911 | 8939 | | |
| |||
8914 | 8942 | | |
8915 | 8943 | | |
8916 | 8944 | | |
8917 | | - | |
| 8945 | + | |
| 8946 | + | |
8918 | 8947 | | |
8919 | 8948 | | |
8920 | 8949 | | |
| |||
8925 | 8954 | | |
8926 | 8955 | | |
8927 | 8956 | | |
8928 | | - | |
8929 | | - | |
8930 | | - | |
8931 | | - | |
| 8957 | + | |
| 8958 | + | |
| 8959 | + | |
| 8960 | + | |
| 8961 | + | |
8932 | 8962 | | |
8933 | 8963 | | |
8934 | 8964 | | |
| |||
0 commit comments