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.
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 rotatingSecurityStampdoes not invalidate a live access token, and a per-request read ofDisabledAtwould mean re-enabling resurrects every unexpired pre-disable token.ApplicationUsergains: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:
RefreshAsyncloads token+user, then revokes and inserts a child, whileRevokeAllActiveForUserAsyncis 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,
CredentialEpochnon-nullable default0, no data statements.API
Separate verbs, following
POST /products/{id}/deactivate.OwnerOnly+Idempotency-Keyfrom the group.ListUsers/GetUsergrowdisabledAt.Guards — and they need an account-wide lock
Users.CannotDisableSelfUsers.LastOwnerA naive check is not race-safe, and
ConcurrencyStampdoes 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:After authentication (before it, every caller looks anonymous and it is inert), before
UseAuthorization(applies regardless ofAuthPoliciestier), before idempotency (a blocked write burns no key). It must look up by bothUserIdandAccountId—ApplicationUserhas no tenant query filter — use an untracked projection, skip re-execution underIExceptionHandlerFeatureasMustChangePasswordMiddlewaredoes, and leaveauth/logoutreachable.LoginAsyncrefuses with the same genericIdentity.InvalidCredentialsas a wrong password, still paying PBKDF2 — never reveal account state.RefreshAsyncrefuses a disabled user and any superseded-epoch token.StepUpGrantService.IssueAsyncrefuses a disabled user.recover-adminBreakGlassResetAsyncclearsDisabledAtand bumps the epoch — otherwise break-glass cannot recover from a disable.AdminRecoveryServicegains an--account+--user-idlookup, since locating by email is circular after an email-typo lockout.Retries
Nothing special.
IdempotencyMiddlewarealready runs the pipeline underSingleAttemptExecution, so the inner Identity save is never independently replayed. A non-HTTP caller wraps the update + revocation + audit inAmbientTransaction(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.tsturns any authenticated 401 into refresh-and-retry then calls a parameterlessonUnauthenticated, andLogin.tsxmaps every 401 to "invalid credentials" — so returningAuth.AccountDisabledis necessary but not sufficient. Plumbing the title through is part of this slice.Tests
recover-adminre-enables and can locate without the email.Docs
GLOSSARY.mdgains 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.