PHI encryption at rest and per-tenant DEK rotation
Outcome
Per-tenant column-level encryption (pgcrypto + Key Vault DEKs) is backfilled, rotated on schedule, and resumable on crash.
Prerequisites
PLATFORM_ADMIN.- Tenant has run migration 113 (per-tenant DEK columns).
- Key Vault admin access.
Scope of encryption
| Column | Notes |
|---|---|
member.member.dob | Plaintext is intentionally not zeroized today (used by ix_member_name_dob). |
member.member_identifier.identifier_value | Only when identifier_type IN ('SSN','MEDICARE_ID'). |
member.coverage_policy.member_number | |
member.eligibility_result.subscriber_id |
Each encrypted row carries an enc_kid SMALLINT tracking which Data
Encryption Key (DEK) version encrypted it. Rotation re-encrypts every
row to a new DEK and bumps enc_kid.
Key topology
| Element | Role |
|---|---|
| DEK material | Lives in Key Vault under phi-dek-${tenantId}-v${kid}. |
Active kid | Recorded in identity.tenant.phi_dek_kid (master DB). |
TenantDekResolver | Caches (tenantId, kid) → dek; provisions a fresh DEK on first use. |
| Old DEKs | Retained in Key Vault — required to decrypt anything ever restored from a backup. Decommissioning is a separate operator step. |
Initial backfill (one-time per tenant after migration 113)
pnpm --filter @rcm/rcm-core backfill-phi --tenant <tenant-id>
The CLI:
- Resolves the tenant DB via
TenantConnectionResolver. - Resolves the active DEK via
TenantDekResolver(provisions one if first use). - For each of the four target tables, finds rows whose
*_encryptedcolumn is NULL or whoseenc_kidis stale, and updates them in batches of 500. - Logs counts per table.
It is idempotent — re-running encrypts only the rows that the previous
run missed. --dry-run forecasts counts without writing.
Rotation drill
# 1. Forecast.
pnpm --filter @rcm/rcm-core rotate-phi-dek --tenant <id> --dry-run
# 2. Rotate.
pnpm --filter @rcm/rcm-core rotate-phi-dek --tenant <id>
# 3. Verify (sample decrypt).
psql "$TENANT_DB_URL" -c \
"SELECT pgp_sym_decrypt(dob_encrypted::bytea, '<new-dek>')
FROM member.member LIMIT 5;"
# 4. Once verified, optionally zeroize legacy plaintext columns.
pnpm --filter @rcm/rcm-core rotate-phi-dek --tenant <id> --zeroize
Rotation:
- Provisions a new DEK at
kid+1in Key Vault. - Sweeps every PHI row, decrypts under the old DEK, re-encrypts under
the new DEK, sets
enc_kid = newKid. - Advances
identity.tenant.phi_dek_kidto the new kid so live writes use it. - Invalidates the resolver's cached entries for the tenant.
Resuming a crashed rotation
The per-row enc_kid makes rotation resumable: each sweep filters on
WHERE enc_kid <> newKid, so a rerun picks up only the rows the
crashed run never updated.
Confirm the new DEK exists at
phi-dek-${tenantId}-v${newKid}.Confirm
identity.tenant.phi_dek_kidis at the OLD kid (the advance happens at the end of rotation).Re-invoke
rotate-phi-dek --tenant <id>. It will detect the already-provisioned new DEK.If
provisionNextcollides (new DEK was already written but the master pointer was never advanced), manually advance the pointer then re-run rotation:UPDATE identity.tenant SET phi_dek_kid = <newKid>WHERE tenant_id = '<tenant-id>';After the pointer is correct, the next
rotate-phi-dekrun will treat<newKid>as the active kid and re-rotate to<newKid+1>. Use that path if any rows are still atenc_kid = oldKid.
Key compromise response 🚨
If a tenant DEK is suspected compromised:
Immediately rotate —
rotate-phi-dek --tenant <id>. New writes go to the new DEK as soon as the master pointer advances.Audit access — search
audit.access_logforaccessed_entity_type IN ('MEMBER', 'COVERAGE_POLICY', 'ELIGIBILITY_RESULT')events around the suspected window. See Audit log viewer.Decommission the compromised DEK — only after confirming no backup restores will rely on it. Hard-purge with
keyVault.deleteSecret(name, { purge: true }). This is irreversible; all future restores from older backups become unreadable for the affected columns.
Caveats
- Repository write-path was NOT migrated. New INSERTs continue to
write plaintext to the legacy columns. Until the follow-up lands,
operators must schedule
backfill-phito run nightly. member.dobplaintext is intentionally not zeroized by the--zeroizeflag — thedobcolumn is onix_member_name_doband is the primary DOB lookup surface. Encrypting AND searching DOB requires the repo write-path migration.- Legacy
member.ssn_encrypted(encrypted underPHI_ENCRYPTION_KEYenv var) is not yet re-keyed under per-tenant DEKs. See PHI key rotation legacy CLI until the migration ships.
Validation
| Check | Expected |
|---|---|
backfill-phi completes | All target rows have enc_kid set |
rotate-phi-dek completes | enc_kid advances on every row |
identity.tenant.phi_dek_kid | Matches the new kid |
| Sample decrypt with new DEK | Returns plaintext |
Cross-references
- PHI key rotation legacy CLI for SSN column.
- Audit log viewer for compromise audit.
- Connection budget alerts — large rotation runs can spike DB load.