Suspend and restore application access (M4a)¶
An administrator can suspend an account's access to SyRF and restore it later. Milestone M4a of the application-authority transition adds this as a reversible, guarded and audited PM lifecycle transition. Design decision P7 is recorded in the design.
Status: dark. The feature exists only with featureFlags.applicationSuspension on. That flag needs enforced
application authority (both application-authority flags), or the API refuses
to start. Every environment is off. With the flag off, the endpoints answer 404 and no request is refused
because of a suspension. No real account has been suspended.
What a suspension is, and is not¶
| Suspension (M4a) | Account deletion (M1c) | |
|---|---|---|
| Stored as | pmInvestigator.ApplicationSuspension (its own field) |
pmInvestigator.Deactivated = true |
| Reversible | Yes, by an administrator | Never |
| Identity account | Kept. Sessions are withdrawn (tokens revoked, then the stamp rotated) | Deleted |
| Roles, memberships | Left exactly as they are | Left as stored |
| Lockouts, recovery state | Untouched | Account removed |
Provenance (P7, confirmed 2026-10-01). Deactivated has exactly one writer: account deletion, through
the M1c finalization and Investigator.DeactivateAccount. Nothing ever clears it, and no restore path exists.
So Deactivated stays the record of an irreversible deletion, and every existing Deactivated: true document
is treated as deleted. A suspension never writes Deactivated. A restore never clears it, and refuses a
deleted account (409 target-deleted). The two states cannot be confused, and the deletion contract is
unchanged.
The order of a suspension¶
POST /api/investigators/{id}/application-suspension with { "reason": "...", "operationId": "<uuid>" }:
- The guard admits it. The request runs one serialized, majority, journaled PM transaction on the same
pmApplicationRoleGuardserialization as role changes and account deletion. Inside it: - the actor must still be an active Administrator (
403otherwise); - an administrator cannot suspend themselves (
409 self-suspension); - the last-active-administrator check runs; a suspended Administrator no longer counts as active.
- The denial commits. In the same transaction:
- the suspension field (the denial);
- a pending-deactivation reservation with source
application-suspension: the marker plus apmInvestigatorDeactivationrecord. This is the same durable reservation an account deletion takes; - an
Audit.Versionfence; - a
pmApplicationSuspensionAuditrecord (actor, target, reason, operation).
From this commit, every authority read on either replica denies the account.
3. Native sessions are withdrawn. Outside any transaction, the API calls Identity's
POST /api/admin/investigators/{id}/session-withdrawal with the reservation's operation ID. Identity checks
the PM marker first, and the check needs both this operation and source application-suspension
(409 deactivation-not-reserved otherwise, with nothing done). It then revokes the account's OpenIddict
tokens and authorizations and rotates its security stamp, which are the two steps account deletion takes
before deleting. It never deletes, locks or unlocks anything.
4. The reservation settles. A confirmed withdrawal, or 404 native-account-not-found (no native account to
withdraw), removes the marker and records the withdrawal as completed or not-applicable. The suspension
itself stays.
| Answer | Meaning |
|---|---|
200 result: suspended, withdrawal: completed/not-applicable |
Suspended and withdrawn. |
202 result: suspension-committed-withdrawal-pending |
The suspension is committed and denies now. Withdrawing native sessions did not complete (Identity unavailable, refused or unknown). Reconcile it (below). |
200 result: already-applied |
The same operationId again (from either replica). suspended says whether that suspension still stands: a late retry after a restore never re-suspends. |
409 already-suspended / self-suspension / last-active-administrator / target-deleted / target-deactivation-pending |
Nothing was written. |
503 authority-unavailable |
Nothing was written. Retry. |
503 role-change-outcome-unknown |
The transaction may or may not have committed. Read the state, or retry with the same operationId. |
Partial withdrawal and recovery¶
A withdrawal that did not complete never undoes the suspension: the reservation is never released. Run
POST /api/investigators/{id}/pending-deactivation/reconcile (role administration). It resumes the
reservation and dispatches on its source, so a suspension's withdrawal is repeated idempotently and never
reaches Identity's delete. The result is 200 withdrawal-completed or withdrawal-not-applicable, or
503 withdrawal-pending (retry later). The same lease rules as deletion apply: while another attempt holds the
3-minute lease, a second reconciler gets 409 withdrawal-pending.
Account deletion never resumes or deletes through a suspension's reservation. A deletion requested while a
withdrawal is pending answers 409 target-deactivation-pending (#3866).
What a suspended account can still do¶
With the flag on, every authenticated API request and every hub connection or invocation re-reads the subject's
PM lifecycle facts. This is one uncached linearizable read, the same reader as every other authority decision.
It refuses a suspended, deleted or pending-deletion subject with 403 and a safe code (subject-suspended,
subject-deactivated, subject-deactivation-pending). An unreadable state gets 503. This holds for old BFF
sessions, refreshed sessions, direct bearer tokens and sockets, on every replica, and also before and without
the native withdrawal.
Under impersonation, both the real actor and the target are checked. Support impersonation (M2b) also refuses a suspended target.
Only three things are exempt: the BFF session endpoints (/api/auth/*), so the user can sign out; the
self-capabilities read (GET /api/account/capabilities), so the UI can show the safe reason; and, for a
suspended subject only, the user's own account deletion (DELETE /api/account/profile, below).
A suspended user may delete their own account¶
Decided by the product owner on 2026-10-02 (#3896): a suspension
never traps an account. DELETE /api/account/profile carries [AllowSuspendedSubject], which admits a subject whose
only inactive state is the suspension. Nothing else changes:
- A deleted or pending-deletion subject is still refused there (
subject-deactivated,subject-deactivation-pending). A suspension whose withdrawal is still pending carries a pending-deactivation reservation, so the deletion reaches the guard and answers409 target-deactivation-pending(#3866) until the withdrawal settles; it never deletes through that reservation. - An unreadable subject state is still
503, and under impersonation the impersonated target is still refused when suspended. - The deletion is the normal M1c path: the pending-deactivation reservation first, then Identity's two-step
(revoke tokens, then rotate the stamp) with its two
503meanings, then finalization. The last-active-administrator guard is unchanged: a suspended Administrator is not active, so their deletion removes no active Administrator, and the last active Administrator is still refused (409 last-active-administrator). - It needs an existing BFF session. Identity still refuses sign-in, authorization and token issuance to a suspended account (#3904), so a suspended user who is signed out cannot start a session to delete: an administrator restores the account, or deletes it through the admin path. Nothing else widens.
- With the flag off nothing is read and nothing is exempt: the attribute is inert.
Token refresh during a PM outage ends the session (decided, fail-closed)¶
Confirmed 2026-10-02: when Identity cannot read the PM suspension state, it refuses the refresh
(temporarily_unavailable) and the BFF ends the session. This is deliberate fail-closed behaviour, not a bug to
soften. It also means a suspended user's existing session may end at its next refresh.
The exemption includes /api/auth/refresh, deliberately: the API does not refuse it. Identity does
(#3896), so a refresh fails for a suspended account even before
the withdrawal or under a PM-only suspension, and the BFF then ends that session. After the withdrawal the refresh
token is revoked as well.
Identity refuses sign-in and token issuance¶
With the flag on (it is rendered into Identity from the same Helm value), Identity reads the account's PM suspension with one uncached linearizable read over its read-only PM connection, the same way it reads the deletion reservation, and refuses:
- Sign-in by any method (password, passkey, external provider, second factor): the account cannot sign in. The page shows the usual generic failure, so the answer reveals nothing about the account.
- Authorization (
/connect/authorize):access_denied, and Identity's own sign-in cookie is ended. - Code exchange and refresh (
/connect/token):invalid_grant.
If the suspension cannot be read, sign-in is refused, authorization answers temporarily_unavailable and the
token endpoint answers temporarily_unavailable. A refused refresh ends the BFF session, so during a PM outage
a user whose access token needs refreshing signs in again afterwards. Off (the default) nothing is read and
nothing changes. Token introspection does not read the suspension: the API's subject admission already refuses
every request.
An administrator can still remove a suspended account's roles. Nothing can be granted to a suspended account
(409 target-suspended). So a restore can only return roles that were left in place, never ones added
meanwhile.
Not covered here: server-pushed deliveries to an already-open socket. That is M4b's realtime contract.
Restore¶
POST /api/investigators/{id}/application-suspension/restore with a reason and an operationId. It removes
only the suspension:
- Roles: stay as they are now. A role revoked while suspended stays revoked.
- Identity: never called. It cannot recreate a deleted or missing account, clear a lockout or a recovery requirement, or revive a withdrawn session. The user must sign in again, which Identity allows as soon as the suspension is gone (it reads the current PM field at every sign-in and issuance).
- Refusals: a deleted account (
409 target-deleted); a suspension whose withdrawal is still pending (409 withdrawal-pending; reconcile first); an account not suspended (409 not-suspended); a pending deletion.
A retry with the same operationId returns 200 restored again (replayed from the audit record).
GET /api/investigators/{id}/application-suspension shows suspended, deleted, withdrawal and
suspendedAt.
Enable, roll back¶
- Enable (only after M6 enforces authority, with an approved operating procedure): set
featureFlags.applicationSuspension: truealongside the two application-authority flags. The value comes from the generator (src/charts/syrf-common/env-mapping.yaml,applicationSuspensionFlags, rendered into the API and, since #3896, Identity). Both refuse to start with it on unless authority is enforced. - Cost: with the flag on, each authenticated request makes one extra linearizable read. A hub invocation makes one read per subject. Identity makes one per sign-in attempt on a mapped account, per authorization and per code exchange or refresh.
- Roll back: turn the flag off. The endpoints disappear. Stored suspensions stay, and still deny
application capabilities, because the evaluator always reads them. But project work is no longer refused
for them. Restore suspended accounts before turning the flag off. With the flag off, the restore endpoint
answers
404, so the only way to restore an account suspended before the rollback is to turn the flag back on. Reconciliation of pending withdrawals still works while authority is enforced.
Tests¶
- PM Mongo.Data
MongoApplicationSuspensionTests(real replica set): commit contents; refusals that write nothing; replay; settlement; source isolation; partial withdrawal and lease recovery; restore boundaries; the stale-aggregate fence; and races of suspension against suspension, role revoke (self and other) and deletion reservation. - API
ApplicationSuspensionTests: order; partial withdrawal; endpoints; reconcile dispatch; subject admission and its middleware. - API
OpenIddictIdentityServiceTests: the withdrawal adapter. - Identity
SuspensionSessionWithdrawalTests. - Identity
ApplicationSuspensionIdentityTests(flag binding, the field's interpretation, sign-in) and the suspension cases ofAuthorizationControllerFailClosedIssuanceTests(authorize, code exchange, refresh). - Lane
authority-m4a-suspension.spec.ts(fixture).