Skip to content

Users: disable / re-enable a user #356

Closed

Description

@mforce

Design: docs/superpowers/specs/2026-08-01-user-disable-and-email-design.md (second draft — the first was reworked after review; see What the first draft got wrong at the end of the spec).

Problem

The Users page can create a user but never take their access away. A worker leaves the farm and the only lever an Owner has is resetting the password — the account stays live, reachable by anyone who learns the new one.

Hard delete is out of scope: users are referenced by audit rows, created-by trails on daily entries and sales orders, and refresh-token history. Personal-data erasure is #272.

Scope

An Owner can disable and re-enable a user. One flag serves both offboarding ("this person left") and suspension ("pending an investigation") — the second sets the enforcement bar.

The mechanism: a credential epoch, not the flag

Access tokens carry sub, email, account_id, role, must_change_password — no security stamp — and validation checks only signature/issuer/audience/lifetime. So rotating SecurityStamp does not invalidate a live access token, and a per-request read of DisabledAt would mean re-enabling resurrects every unexpired pre-disable token.

ApplicationUser gains:

public DateTimeOffset? DisabledAt { get; set; }   // null = active; a record, not the gate
public Guid? DisabledBy { get; set; }             // denormalized audit metadata, no FK
public int CredentialEpoch { get; set; }          // monotonic; the gate

The epoch is stamped into every access token and compared per request. Disable bumps it; enable does not bump it and does not restore the old value. Password resets (self-service, Owner SetUserPassword, break-glass) bump it too — one invariant, not re-derived per path.

This also closes a race bulk revocation cannot: RefreshAsync loads token+user, then revokes and inserts a child, while RevokeAllActiveForUserAsync is a separate bulk update — so a refresh in flight can insert a live child after the revoke commits. Minted under the old epoch, that child is useless.

One migration, three columns, CredentialEpoch non-nullable default 0, no data statements.

API

POST /api/v1/users/{id}/disable   -> 204   (step-up)
POST /api/v1/users/{id}/enable    -> 204   (step-up)

Separate verbs, following POST /products/{id}/deactivate. OwnerOnly + Idempotency-Key from the group. ListUsers/GetUser grow disabledAt.

Guards — and they need an account-wide lock

Condition Result
Target is the caller 422 Users.CannotDisableSelf
Target is the last active Owner 422 Users.LastOwner
Target not in this account 404

A naive check is not race-safe, and ConcurrencyStamp does not save it: Owners A and B disable each other, each reads two active Owners, each targets a different row, both commit, zero Owners remain. There is no shared concurrency token across two rows.

Take the account row FOR UPDATE (AccountRepository.GetCurrentLockedAsync), re-read the target + Owner membership + active-Owner count inside the lock, then write. Every future Owner-removing path (#355 demotion, any eventual delete) must take the same lock.

Enforcement

New CredentialEpochMiddleware:

UseAuthentication → TenantResolutionMiddleware → CredentialEpoch → MustChangePassword → UseAuthorization → Idempotency

After authentication (before it, every caller looks anonymous and it is inert), before UseAuthorization (applies regardless of AuthPolicies tier), before idempotency (a blocked write burns no key). It must look up by both UserId and AccountId — ApplicationUser has no tenant query filter — use an untracked projection, skip re-execution under IExceptionHandlerFeature as MustChangePasswordMiddleware does, and leave auth/logout reachable.

LoginAsync refuses with the same generic Identity.InvalidCredentials as a wrong password, still paying PBKDF2 — never reveal account state. RefreshAsync refuses a disabled user and any superseded-epoch token. StepUpGrantService.IssueAsync refuses a disabled user.

recover-admin

BreakGlassResetAsync clears DisabledAt and bumps the epoch — otherwise break-glass cannot recover from a disable. AdminRecoveryService gains an --account + --user-id lookup, since locating by email is circular after an email-typo lockout.

Retries

Nothing special. IdempotencyMiddleware already runs the pipeline under SingleAttemptExecution, so the inner Identity save is never independently replayed. A non-HTTP caller wraps the update + revocation + audit in AmbientTransaction (owned path, already single-attempt).

SPA

Disabled users listed inline, "Disabled" badge, de-emphasized row, action toggles to "Enable". Confirm dialog on disable via useConfirm(); enable is one click.

The 401 reason must survive auth teardown. client.ts turns any authenticated 401 into refresh-and-retry then calls a parameterless onUnauthenticated, and Login.tsx maps every 401 to "invalid credentials" — so returning Auth.AccountDisabled is necessary but not sufficient. Plumbing the title through is part of this slice.

Tests

  • Guards: self-disable, last-active-Owner, both directions.
  • A live access token stops working on the next request (fails if the middleware is dropped).
  • Re-enabling does not resurrect a pre-disable access token (fails if the epoch degrades to a boolean).
  • Disabled user cannot log in (indistinguishable from wrong password) or refresh; step-up grants die and none can be issued.
  • Foreign-account id → 404. recover-admin re-enables and can locate without the email.
  • Barrier-controlled races — sequential tests pass with all of these bugs present: two Owners disabling each other; refresh in flight across a disable; step-up issuance racing a disable.
  • Middleware ordering asserted explicitly — the guarantee is positional and a silent reorder fails open.
  • SPA Vitest incl. the disabled reason reaching the login screen.

Docs

GLOSSARY.md gains disabled user and credential epoch; Help page covers what disabling does and that it is reversible with history retained. Strings ship with es/tl inline.

Ships before #357.

Activity

  1. mforce commented on Aug 2, 2026

    @mforce
    OwnerAuthor

    Blocked by #364, and the ordering is a correctness constraint, not sequencing preference.

    Codex round 6 on the spec PR found that the credential-epoch mechanism cannot ship in the same deploy as these mutations. An old replica runs the pre-epoch pipeline with no CredentialEpochMiddleware, so during a rolling deploy an Owner could disable a user on a new replica while the load balancer keeps routing that user's pre-deploy access token to an old one — which authorizes it. The suspension would be immediate on part of the fleet, which is worse than a documented delay because nothing surfaces it.

    So the epoch, the migration/cutover, the middleware, RefreshAsync's comparison, and the SPA 401 plumbing all move to #364, which ships inert and makes no new promise. This issue keeps the mutations: POST /disable, POST /enable, the account-row FOR UPDATE guard, the recover-admin changes, and the SPA toggle + confirm dialog.

    Do not expose this until #364 is deployed and every pre-#364 process is drained. An orchestrator that reports "rollout complete" because the new replicas are healthy, without confirming the old ones are gone, does not satisfy that.

    The issue body above still describes the combined design — it will be trimmed to the mutations-only scope when the spec settles.

  2. mforce commented on Aug 2, 2026

    @mforce
    OwnerAuthor

    Scope narrowed further (codex round 8, ea9820e): the disabled-state readers move to #364, leaving this issue with the mutations and UI only.

    Specifically, #364 now carries DisabledAt/DisabledBy, and the disabled checks in LoginAsync, RefreshAsync and StepUpGrantService.IssueAsync. This issue keeps POST /disable, POST /enable, the account-row FOR UPDATE guard, the recover-admin changes, and the SPA toggle + confirm dialog.

    The reason is this issue's own rollout: if the disabled checks shipped here, a replica part-way through the rollout could mint a current-epoch token for a user another replica had just disabled — and every updated replica would honour it, because the epoch is valid.

    One more sequencing requirement for this deploy: stage the API across the fleet before exposing the SPA. Otherwise a browser that loads the new bundle from an updated replica can have /disable routed to one without the route yet, producing intermittent 404s on the new actions.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions