Skip to content

Users: disable / re-enable a user #356

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

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