Repository navigation
Description
Found while reviewing the design for #356/#357 (docs/superpowers/specs/2026-08-01-user-disable-and-email-design.md). Independent of those slices — it is a gap in #308's own coverage.
The gap
CreateUserHandler requires a step-up grant only when the new user's role is Owner:
if (role == Roles.Owner)
{
var proof = await stepUp.ValidateAsync(accountId, actingUserId, command.StepUpToken, ct);
if (!proof.IsSuccess) return Result.Failure<Guid>(proof.Error);
}SetUserPasswordHandler is gated on the same condition — resetting a non-Owner's password needs no step-up (pinned by ResetWorkersPassword_NeedsNoStepUp_OrdinaryAdministrationStaysUngated).
So a stolen Owner access token — good for ~15 minutes — converts into durable access without ever meeting a step-up prompt:
GET /api/v1/usersto enumerate the account (ungated).POST /api/v1/userscreating a Manager with an attacker-controlled email and password (ungated — only Owner creation is gated).- The Owner token expires. The attacker keeps a Manager account: farm operations, inventory, reports, "undo, correct, configure, see money" per
AuthPolicies.AdminOnly.
Step 2 can be swapped for "reset an existing Worker's password" with the same result and less noise — a real account whose owner will report a lockout eventually, but not immediately.
The reasoning in CreateUserHandler is that only a second Owner "multiplies durable account control past a stolen access token's own lifetime". That is true of Owner creation but not exclusive to it: a Manager also outlives the token, and so does any account whose password the attacker just set.
Why it wasn't caught
#308's design explicitly avoids a blanket prompt, and the Owner/non-Owner line is a reasonable first cut at "what multiplies control." The miss is that durability, not privilege level, is what the step-up gate is defending — and every user-creation and every administrative password reset is durable.
Options
- Gate all administrative user creation and all administrative password resets. Closes it completely. Costs a re-auth prompt on ordinary farm administration, which is the thing Require recent step-up authentication for privileged user administration #308 set out to avoid.
- Gate by durability, not role: any operation that mints or resets a credential someone else will hold. In practice that is the same set as (1), stated in terms that generalize to future endpoints.
- Shorten the access-token lifetime and accept the residual. Cheapest, weakest — 15 minutes is already short, and the attack needs one request.
- Re-prompt per session rather than per operation — one step-up unlocks a short administration window. Middle ground; needs a real design.
(2) is the one worth designing, since it also tells the #355 role-editing slice what to do.
Related, already known
Step-up grants live in an in-process registry (InMemoryStepUpGrantRegistry), so "single-use" holds per replica — #338. That is a separate residual, and it gets more load-bearing as more operations depend on step-up.
Not blocking
#356 and #357 gate their own operations regardless; they neither introduce nor widen this. The spec for those slices records the corrected threat model rather than repeating the original overstatement.
Activity
fix(auth): require step-up for durable user access (#360) (#607)
chore(main): release 0.1.0 (#542)