Skip to main content

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

StatusReadsWritesNotes
activeallowallowDefault.
read_onlyallow403 TENANT_READ_ONLYPlatform support (impersonation) can still write. Use for billing holds, compliance pauses, migration cutovers.
suspended503 TENANT_SUSPENDED503 TENANT_SUSPENDEDHard cutoff. Impersonation does not bypass.
offboardingallow403 TENANT_OFFBOARDINGSelf-export window. Platform support can still write to assist migration.

Steps

  1. 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:

    1. Verifies the slug exists in identity.tenant.
    2. Rejects no-op transitions (already at target status).
    3. Updates identity.tenant.status and writes an identity.tenant_audit row (event_type = STATUS_CHANGED) with { from, to, reason } in a single transaction.

    Add --dry-run to preview without writing.

  2. Wait for propagation

    PathPropagation window
    Read paths (JWT-backed)Up to JWT_ACCESS_TOKEN_EXPIRY (default 1 h) for users already signed in.
    Write pathsUp 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_only or suspended, do a rolling restart of the rcm-core fleet so every worker re-resolves status on first request.

  3. 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).
  4. 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

CheckExpected
identity.tenant.status for the slugmatches your --to
identity.tenant_audit newest rowevent_type=STATUS_CHANGED, details.from/to/reason populated
Customer write requestrejected with the right code (403 read_only / 503 suspended)
Customer read request (read_only only)succeeds

Troubleshooting

SymptomCauseFix
CLI: tenant slug not foundTypo, or tenant lives in a different masterConfirm the slug in identity.tenant.
CLI: no-op transitionAlready at target statusEither expected, or someone else flipped it — check the audit log.
Writes still succeed for 30+ s after flipStatus cache hasn't expiredWait one TTL window, or rolling-restart the app.
Reads still succeed after flip to suspendedUser's JWT was minted before the flipReads stop on next token refresh (≤ JWT_ACCESS_TOKEN_EXPIRY). For immediate effect, force-revoke.
Audit row missing despite successful flipDB transaction was committed by something other than this CLIThe 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;

Next

1.4 — Backup and restore