Suspend or flip a tenant to read-only
Outcome
You can pause writes (read_only) or all traffic (suspended) for a single
tenant without touching others. Every transition is audited.
Prerequisites
PLATFORM_ADMIN(CLI access).- A reason to capture in the audit row — billing hold, compliance pause, migration cutover, incident triage.
Status semantics
| Status | Reads | Writes | Notes |
|---|---|---|---|
active | allow | allow | Default. |
read_only | allow | 403 TENANT_READ_ONLY | Platform support (impersonation) can still write. Use for billing holds, compliance pauses, migration cutovers. |
suspended | 503 TENANT_SUSPENDED | 503 TENANT_SUSPENDED | Hard cutoff. Impersonation does not bypass. |
offboarding | allow | 403 TENANT_OFFBOARDING | Self-export window. Platform support can still write to assist migration. |
Steps
Run the status CLI with a free-text reason.
pnpm --filter @rcm/rcm-core tenant-status \--slug acme \--to read_only \--reason "Pre-migration cutover for 2026-04-30 deploy"The CLI:
- Verifies the slug exists in
identity.tenant. - Rejects no-op transitions (already at target status).
- Updates
identity.tenant.statusand writes anidentity.tenant_auditrow (event_type = STATUS_CHANGED) with{ from, to, reason }in a single transaction.
Add
--dry-runto preview without writing.- Verifies the slug exists in
Wait for propagation
Path Propagation window Read paths (JWT-backed) Up to JWT_ACCESS_TOKEN_EXPIRY(default 1 h) for users already signed in.Write paths Up to TENANT_STATUS_CACHE_TTL_MS(default 30 s) — driven by the master-backed status cache.For an immediate cutoff in production after flipping
read_onlyorsuspended, do a rolling restart of the rcm-core fleet so every worker re-resolves status on first request.Confirm by spot-checking a request the customer would have made.
curl -i -X POST https://acme.medsuite.com/api/v1/claims \-H "Authorization: Bearer $TENANT_USER_TOKEN" \-H "Content-Type: application/json" \-d '{}'# Expect 403 TENANT_READ_ONLY (or 503 TENANT_SUSPENDED).Restore when you're done
pnpm --filter @rcm/rcm-core tenant-status \--slug acme --to active --reason "Cutover complete"The same audit machinery records the return-to-active transition.
Validation
| Check | Expected |
|---|---|
identity.tenant.status for the slug | matches your --to |
identity.tenant_audit newest row | event_type=STATUS_CHANGED, details.from/to/reason populated |
| Customer write request | rejected with the right code (403 read_only / 503 suspended) |
| Customer read request (read_only only) | succeeds |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
CLI: tenant slug not found | Typo, or tenant lives in a different master | Confirm the slug in identity.tenant. |
CLI: no-op transition | Already at target status | Either expected, or someone else flipped it — check the audit log. |
| Writes still succeed for 30+ s after flip | Status cache hasn't expired | Wait one TTL window, or rolling-restart the app. |
Reads still succeed after flip to suspended | User's JWT was minted before the flip | Reads stop on next token refresh (≤ JWT_ACCESS_TOKEN_EXPIRY). For immediate effect, force-revoke. |
| Audit row missing despite successful flip | DB transaction was committed by something other than this CLI | The CLI is the only authorized path; investigate. |
Querying audit history for a tenant
SELECT occurred_at, details, platform_user_id, ip_address
FROM identity.tenant_audit
WHERE event_type = 'STATUS_CHANGED'
AND tenant_id = (SELECT id FROM identity.tenant WHERE slug = 'acme')
ORDER BY occurred_at DESC;