Skip to content

Step-up (#308) gates only Owner creation — a stolen Owner token still buys durable access #360

Description

@mforce

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:

  1. GET /api/v1/users to enumerate the account (ungated).
  2. POST /api/v1/users creating a Manager with an attacker-controlled email and password (ungated — only Owner creation is gated).
  3. 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

  1. 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.
  2. 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.
  3. Shorten the access-token lifetime and accept the residual. Cheapest, weakest — 15 minutes is already short, and the attack needs one request.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerbugSomething isn't workingepic-1.5

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions