Skip to content

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, nobody is refused because of a suspension, suspend and restore answer 404, and an administrator can still read a stored suspension (see With the flag off). 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>" }:

  1. The guard admits it. The request runs one serialized, majority, journaled PM transaction on the same pmApplicationRoleGuard serialization as role changes and account deletion. Inside it:
  2. the actor must still be an active Administrator (403 otherwise);
  3. an administrator cannot suspend themselves (409 self-suspension);
  4. the last-active-administrator check runs; a suspended Administrator no longer counts as active.
  5. The denial commits. In the same transaction:
  6. the suspension field (the denial);
  7. a pending-deactivation reservation with source application-suspension: the marker plus a pmInvestigatorDeactivation record. This is the same durable reservation an account deletion takes;
  8. an Audit.Version fence;
  9. a pmApplicationSuspensionAudit record (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 answers 409 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 503 meanings, 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. With the flag on, 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. A role granted while the flag was off is the exception: it stays, and it confers nothing while the suspension is enforced.

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, suspendedAt and enforced (whether the flag is on). It needs enforced application authority and current role administration authority, and it answers with the flag on or off.

Enable, roll back

  • Enable (only after M6 enforces authority, with an approved operating procedure): set featureFlags.applicationSuspension: true alongside the two application-authority flags. The value comes from the generator (src/charts/syrf-common/env-mapping.yaml, applicationSuspensionFlags, rendered into the API, Identity since #3896, and project-management since #4290). The API and Identity refuse to start with it on unless authority is enforced. Project-management reads it only to decide whether its delegated-work check enforces a stored suspension. During a rollout the services can briefly disagree; each one either enforces or ignores the suspension, and they agree once the rollout finishes.
  • 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 (switch off): turn the flag off in the API, project-management and Identity (one Helm value). It is a kill switch: off means suspensions aren't enforced. See With the flag off for what that does to people who are suspended now.

With the flag off

Decided by Chris on 2026-10-17 (#4290, option B): off means suspensions aren't enforced, and administrators can see who that affects. The likeliest reason to switch the flag off is that the suspension check is misbehaving, so switching off stops the check completely and returns the behaviour from before the flag. Saved suspensions are kept and shown, but not enforced.

  • Nobody is refused because of a suspension. Identity does not read it, so a suspended account can sign in again. The API's per-request admission reads nothing. The application capability decisions (administrator abilities included), realtime delivery, support impersonation and the delegated-work check all ignore it, and a role can be granted to a suspended account.
  • So everyone who is suspended can get back in while the flag is off. Switching off should be a deliberate, short step. Before switching off, read who is suspended (below) so you know who it affects.
  • Suspensions are kept. Nothing changes or deletes a stored suspension, its audit record or its reservation. GET …/application-suspension still answers, read-only, with enforced: false.
  • No changes while off. Suspend and restore answer 404. A new suspension waits until the flag is on again. Reconciling a pending withdrawal (POST …/pending-deactivation/reconcile) still works while authority is enforced, so work already started settles.
  • Switching back on enforces every stored suspension again at once, unchanged. The next request, sign-in or refresh of a suspended account is refused, and restore works as before.

Two things still apply while the flag is off:

  • A suspended Administrator still does not count as an active Administrator in the last-active-administrator check. Counting them would let a change made while off leave no active Administrator once the flag is back on.
  • A suspension whose session withdrawal has not settled (202 suspension-committed-withdrawal-pending) keeps its pending-deactivation reservation, and a reservation still denies application capabilities, whatever its source. Reconcile it to settle it.

The web app has no suspension page yet, so there is no list of suspended people in the UI. Read each account with the GET endpoint. A read-only list with a "suspensions aren't being enforced" warning is a follow-up.

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; the flag-off GET and the on, off, on round trip (#4290).
  • PM Core ApplicationSuspensionEnforcementTests and Mongo.Data MongoApplicationSuspensionTests.OffIgnoresAStoredSuspensionWithoutTouchingItAndOnEnforcesItAgainUnchanged (real replica set): with the flag off a stored suspension is ignored and left untouched, and it is enforced again when the flag is back on. DelegatedWorkRegistryTests: project-management reads the same flag.
  • API OpenIddictIdentityServiceTests: the withdrawal adapter.
  • Identity SuspensionSessionWithdrawalTests.
  • Identity ApplicationSuspensionIdentityTests (flag binding, the field's interpretation, sign-in, and the on, off, on round trip against one stored suspension) and the suspension cases of AuthorizationControllerFailClosedIssuanceTests (authorize, code exchange, refresh).
  • Lane authority-m4a-suspension.spec.ts (fixture).