# PHI Encryption & Access Logging — Operations Runbook

Phase 32 encrypts sensitive patient demographics at rest and records who accessed PHI. This runbook covers key management, migration, rotation, compliance, and troubleshooting.

---

## Architecture summary

| Layer | Mechanism |
|---|---|
| Encryption at rest | ASP.NET Data Protection API (AES-256-GCM) via EF Core value converters |
| Encrypted columns | `first_name`, `last_name`, `date_of_birth`, `allergies`, `emergency_contact_name`, `emergency_contact_phone` |
| Plaintext (by design) | `mrn` — exact-match lookup only |
| Name search | HMAC-SHA256 `name_search_token` index — search without decrypting all rows |
| Access audit | Append-only `phi_access_logs` table; written by `PhiAccessLogService` on patient Get/List/Register/Update |

Configuration lives in `appsettings.json` under `PhiEncryption` and `DataProtection`. Implementation: `PhiEncryptionService`, `PatientPhiConverterConfigurator`, `PatientService`.

---

## Secrets and key storage

**Never commit:**

- `data-protection-keys/` (Data Protection key ring)
- Production `PhiEncryption:SearchTokenKey`
- Any Key Vault / KMS credentials

`data-protection-keys/` is listed in `.gitignore`. Treat loss of this directory as **unrecoverable data loss** for encrypted PHI columns.

| Environment | Data Protection key ring | Search HMAC key (`SearchTokenKey`) |
|---|---|---|
| Development | `./data-protection-keys/` on disk (`DataProtection:KeyPath`) | `appsettings.json` (dev placeholder only) |
| Production | Azure Key Vault XML blob or AWS KMS-backed store | Key Vault / Secrets Manager secret — **not** appsettings |

---

## Initial deployment / encrypting existing rows

New patients are encrypted automatically on save. Existing plaintext rows need a one-time re-save.

### Option A — CLI (recommended)

```bash
./scripts/encrypt-existing-patient-phi.sh
```

Or directly:

```bash
dotnet run --project VigilCareClinicalAPI -- encrypt-phi
```

Loads every patient through EF, applies value converters, and recomputes `name_search_token`.

### Option B — Startup hosted service

`PatientPhiMigrationService` runs on application start and backfills search tokens for rows that are not yet encrypted. It is registered in `Program.cs`. Disable or remove after the first successful production deploy to avoid redundant work on every restart.

---

## Verifying encryption

### Automated tests

```bash
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~PhiEncryption"
```

### Live stack (API + Postgres running)

```bash
./scripts/run-phase32-verification.sh
```

### Manual DB check

Encrypted `first_name` values will **not** equal the patient's plaintext name:

```sql
SELECT id, mrn, left(first_name, 30) AS encrypted_prefix
FROM patients
LIMIT 5;
```

Prefix often starts with `CfDJ8` (Data Protection default).

### PHI access logs

```bash
# Admin token required (AuditRead permission)
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:5270/api/v1/phi-access-logs?patientId=<uuid>"
```

Prometheus metric: `phi_access_logs_total{access_type="VIEW|LIST|SEARCH|CREATE|UPDATE"}`.

---

## Key rotation

**Always back up the current key ring before rotating.**

### Encryption keys (Data Protection)

1. Snapshot `data-protection-keys/` (or export Key Vault blob).
2. Deploy new key ring **alongside** the old one (Data Protection supports multiple keys; newest encrypts, all decrypt).
3. Change `PhiEncryption:ProtectorPurpose` to a new version string (e.g. `VigilCare.PatientPhi.v2`).
4. Re-run encrypt command to re-encrypt all patient rows with the new protector:
   ```bash
   dotnet run --project VigilCareClinicalAPI -- encrypt-phi
   ```
5. Verify API reads and raw DB ciphertext look correct.
6. Retire old keys only after confirming all rows decrypt successfully.

### Search HMAC key (`SearchTokenKey`)

1. Store new key in secrets manager.
2. Update `PhiEncryption:SearchTokenKey` in configuration.
3. Re-run `encrypt-phi` (recomputes all `name_search_token` values).
4. Confirm name search still works (`GET /api/v1/patients?q=First+Last`).

Rotating one key without the other does not require touching the other, but both rotations need a full patient re-save.

---

## PHI access log retention (HIPAA)

`phi_access_logs` records who accessed patient demographics, when, from which path, and (hashed) search terms. HIPAA requires audit controls; retention guidance is **minimum 6 years**.

This phase creates the table and query API (`GET /api/v1/phi-access-logs`). Automated retention/archival policy is **out of scope** — plan for Phase 33+ (partitioning, cold storage, or purge job with legal review).

Query endpoints:

| Endpoint | Permission |
|---|---|
| `GET /api/v1/phi-access-logs` | `AuditRead` |
| `GET /api/v1/phi-access-logs/patients/{id}` | `PatientsRead` |

---

## Cross-system PHI (out of scope)

These paths are **not** covered by column encryption in PostgreSQL:

| System | Risk | Action |
|---|---|---|
| Elasticsearch patient documents | May index decrypted names at index time | Exclude PHI fields or accept decrypted-at-index policy |
| Kafka / data lake Parquet events | `patientName` in outbox payloads (e.g. encounter events) | Review lake schemas; redact or tokenize in a future phase |
| Application logs | Serilog must not log PHI fields | Audit log configuration |

---

## Troubleshooting

### `CryptographicException` / cannot decrypt patient

- Key ring missing or wrong `DataProtection:KeyPath`
- App deployed to new host without copying `data-protection-keys/`
- `ProtectorPurpose` changed without re-running `encrypt-phi`

**Fix:** Restore key ring from backup. Do not delete old keys until all data is re-encrypted.

### Name search returns no results

- `name_search_token` is null on older rows → run `encrypt-phi`
- `SearchTokenKey` changed without token recompute
- Query format: two-term search uses `First Last` (space-separated); single term matches first or last name only

### PHI access logs empty

- Caller not authenticated (`PhiAccessLogService` skips unauthenticated requests)
- `PhiEncryption:LogListAccess` is `false` (list/search aggregate logs suppressed)
- Integration/FHIR paths must still authenticate (Phase 31 Integration role)

### Column length errors on encrypt

Migration `WidenPhiEncryptedColumns` widens `first_name`, `last_name`, and emergency contact columns to `text`. Ciphertext is longer than plaintext. If new columns are added to encryption, ensure DB column types accommodate protected payload size.

### Tests fail with 500 on patient register

- Confirm migrations applied (`AddPatientNameSearchToken`, `AddPhiAccessLogs`, `WidenPhiEncryptedColumns`)
- Confirm `PhiEncryption:SearchTokenKey` is set in configuration
- Confirm `data-protection-keys/` is writable in the API working directory

---

## Related files

| File | Purpose |
|---|---|
| `VigilCareClinicalAPI/Services/PhiEncryptionService.cs` | Encrypt/decrypt + HMAC tokens |
| `VigilCareClinicalAPI/Services/PhiAccessLogService.cs` | Access audit writes |
| `VigilCareClinicalAPI/Commands/EncryptPhiCommand.cs` | Bulk re-save CLI |
| `VigilCareClinicalAPI/BackgroundServices/PatientPhiMigrationService.cs` | Startup token backfill |
| `scripts/encrypt-existing-patient-phi.sh` | Wrapper for encrypt CLI |
| `scripts/run-phase32-verification.sh` | End-to-end verification |
| `docs/plans/phase-32-plan.md` | Implementation plan and design rationale |
