Skip to content

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

Closed

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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