Notifications PHI-at-Rest Encryption — Design Spec¶
Date: 2026-04-22
Status: Draft — autonomous design, user review pending
Depends on: Prisma 7 upgrade (PR #23, merged). Uses Prisma 7's omit config for default field exclusion.
Retires: The two TODO(phi) comments in services/notifications/src/notifications/notifications.service.ts:50 and services/notifications/src/events/event-consumer.service.ts:143.
1. Scope, Goals, Non-Goals¶
Goal¶
Encrypt PHI-carrying fields on the Notification table at rest when notification.phi === true, so notifications referencing clinical data (e.g. case-ready-practitioner email with patient initials) are not stored in plaintext in the notifications database. Retire the two TODO comments introduced in PR #21.
Why¶
The notifications service design spec (docs/superpowers/specs/2026-04-21-notifications-service-design.md Section 3) specifies per-row PHI encryption:
"For v1, keep a simple
phiflag on the row and encryptvariables+resolvedAddressonly whenphi: true."
PR #21 shipped the phi boolean column on the Notification model but deferred the encryption work — inline TODO(phi) comments at the two variables: write sites mark the gap. This spec addresses that.
In scope¶
- Introduce a service-wide DEK for the notifications service, wrapped via a
KeyProvider(reusing theLocalKeyProviderinterface from@sa-platform/common). - Encrypt these fields on write when
phi === true: Notification.variables(JSON, stringified before encrypt)Notification.resolvedAddressNotification.errorDetail- Decrypt on read where needed:
- Worker pipeline (rendering, provider send)
- Admin
GET /v1/notifications/:idendpoint (ops inspection) - Adopt Prisma 7
omitconfig to default-omit the three encrypted columns from every query. Callers that need them opt in per-query withomit: { <field>: false }. - Prisma schema: widen the three columns to accommodate ciphertext (base64 grows ~33%, plus IV and auth tag —
Text/LongTextas appropriate).variablesis alreadyJson?; we change it toString? @db.LongTextto hold ciphertext strings.resolvedAddressanderrorDetailare alreadyText. - New service wiring:
KeyProvidermodule (copy pattern from clinical-api), DEK resolver for the service-wide key (simpler than clinical-api's per-patient model), config for key material.
Out of scope (explicitly deferred)¶
- Per-org or per-recipient DEK scoping. Service-wide is sufficient for notifications' short retention and derivative-PHI profile.
- Backfill existing unencrypted rows. Notifications service is days old, near-zero production volume. New rows use the new format; existing rows stay readable until they age out. A backfill migration is a one-shot script we can add later if needed.
- Encrypting
DeliveryAttempt.request— already redacted (no PHI in the request payload). - Encrypting audit-event payloads — already PHI-free by design (events carry only
notificationId,channel,templateCode,errorCode; no PHI). - KMS integration.
LocalKeyProvider(env-var-derived key) is fine for v1;KmsKeyProviderinterface is already defined in@sa-platform/commonfor future swap. - Per-field encryption selection via schema annotations. Static field list in code is simpler.
- Rotating DEKs. The pattern supports it (KEK wraps DEK) but v1 doesn't implement rotation.
Success criteria¶
- All 3 columns store ciphertext (
iv:ciphertext:authTagbase64 format fromCryptoService.encrypt) whenphi === true. - Plaintext round-trip: worker renders templates correctly, admin GET returns decrypted values.
- Prisma 7
omitconfig prevents accidental leaks — default query results omit encrypted fields entirely; callers opt in explicitly. - Both TODO comments removed from the codebase.
- Full test suite (unit + integration) green, including updated tests that verify ciphertext in the DB and plaintext through the service layer.
- No behavioural change for non-PHI notifications (
phi: false) — they keep reading/writing plaintext.
2. Architecture¶
Module layout¶
Two new files in services/notifications/src/crypto/:
services/notifications/src/crypto/
├── crypto.module.ts # Provides DekResolver + KeyProvider
├── dek-resolver.ts # Service-wide DEK resolver (simpler than clinical-api's)
└── dek-resolver.spec.ts
Reuse from @sa-platform/common:
CryptoService(already used elsewhere) — the AES-256-GCMencrypt/decryptprimitive.KeyProviderinterface +LocalKeyProviderimplementation.
DEK resolver shape¶
@Injectable()
export class NotificationsDekResolver {
private cachedDek: Buffer | null = null;
constructor(
@Inject(KEY_PROVIDER) private readonly keyProvider: KeyProvider,
private readonly config: AppConfigService,
) {}
async getDek(): Promise<Buffer> {
if (this.cachedDek) return this.cachedDek;
const wrapped = this.config.config.wrappedDek;
if (!wrapped) {
throw new Error('NOTIFICATIONS_WRAPPED_DEK is required for PHI encryption');
}
this.cachedDek = await this.keyProvider.unwrapKey(wrapped);
return this.cachedDek;
}
}
Service-wide: one DEK for the whole notifications service, cached in memory after first unwrap. Simpler than clinical-api's per-patient resolution because notifications don't have per-entity isolation.
Config¶
Add to AppConfigService (already has patterns for required() and optional env vars):
NOTIFICATIONS_WRAPPED_DEK— required if PHI encryption is enabled. Format: whateverKeyProvider.wrapKey()produced. ForLocalKeyProvider, this is an env-var-derived key encoded as base64.NOTIFICATIONS_KEY_PROVIDER— defaults tolocal. Future values:kms.LOCAL_KEK_BASE64— raw KEK forLocalKeyProviderto wrap/unwrap DEKs. Same env-var pattern clinical-api uses.
For dev, provide sensible defaults in .env.example and a helper pnpm prisma:seed or similar command that generates a fresh DEK if the env var is absent. Simplest: skip the seed helper and require the env var — dev gets a fixed value in .env.example.
Prisma schema changes¶
model Notification {
// ... existing fields ...
// Changed — widen to LongText for ciphertext, add omit
variables String? @db.LongText // was Json?
// resolvedAddress already String? @db.Text — no change, but:
// All 3 fields get omit:true in prisma.config.ts (applied globally)
}
Why variables widens from Json? to String? @db.LongText: Prisma's Json? stores as JSON column type; ciphertext is a base64 string, not JSON. We store the encrypted string. Plaintext variables are stringified before encryption, parsed back on decrypt. For phi: false notifications, variables still stores JSON — but as a JSON-stringified string rather than a native JSON column. Acceptable — the shape is already opaque via the worker's variables as Record<string, unknown> cast.
Alternative considered: keep variables as Json? and put ciphertext in a new encryptedVariables column. Rejected — two columns doing one job is worse.
Prisma 7 omit config¶
Create services/notifications/prisma.config.ts (or add to the existing one) with a global omit:
export default {
omit: {
notification: {
variables: true,
resolvedAddress: true,
errorDetail: true,
},
},
};
Every prisma.notification.findX call will omit these three columns by default. Callsites that legitimately need them pass omit: { variables: false } etc.
Callsite audit and updates:
| Site | Current query | Needs decrypt? | Change |
|---|---|---|---|
notifications.service.ts create() |
prisma.notification.create({ data: ... }) |
N — creates new, writes ciphertext via service layer | No read change; wrap the data construction with encryption |
notifications.service.ts getById() |
findUnique({ include: { attempts_: true } }) |
Y — admin needs plaintext | Add omit: { variables: false, resolvedAddress: false, errorDetail: false }. Decrypt in service before returning |
notifications.service.ts list() |
findMany({ where, orderBy, take }) |
N — list view shouldn't show decrypted PHI | Keep default omit. No decryption. |
worker.service.ts process() — load by id |
findUnique({ where: { id } }) |
Y — worker needs variables + resolvedAddress | Opt in per-field. Decrypt what it uses. |
event-consumer.service.ts transaction |
tx.notification.create({ data: ... }) |
N — writes ciphertext | Wrap data with encryption |
Tests will need their mocked Prisma calls updated to match the new omit: { ... } options.
Encryption/decryption integration¶
Introduce a small NotificationCryptoHelper in services/notifications/src/crypto/helper.ts that encapsulates the field-level PHI logic:
@Injectable()
export class NotificationCryptoHelper {
constructor(
private readonly crypto: CryptoService,
private readonly dek: NotificationsDekResolver,
) {}
async encryptRow(input: {
phi: boolean;
variables: Record<string, unknown>;
resolvedAddress?: string | null;
errorDetail?: string | null;
}): Promise<{
variables: string; // always a string in the DB now
resolvedAddress?: string | null;
errorDetail?: string | null;
}> {
const plaintextVars = JSON.stringify(input.variables ?? {});
if (!input.phi) {
return {
variables: plaintextVars,
resolvedAddress: input.resolvedAddress ?? null,
errorDetail: input.errorDetail ?? null,
};
}
const dek = await this.dek.getDek();
return {
variables: this.crypto.encrypt(plaintextVars, dek),
resolvedAddress: input.resolvedAddress
? this.crypto.encrypt(input.resolvedAddress, dek)
: null,
errorDetail: input.errorDetail ? this.crypto.encrypt(input.errorDetail, dek) : null,
};
}
async decryptRow<
T extends {
phi: boolean;
variables?: string | null;
resolvedAddress?: string | null;
errorDetail?: string | null;
},
>(
row: T,
): Promise<
T & {
variables: Record<string, unknown>;
resolvedAddress: string | null;
errorDetail: string | null;
}
> {
if (!row.phi) {
return {
...row,
variables: row.variables ? (JSON.parse(row.variables) as Record<string, unknown>) : {},
resolvedAddress: row.resolvedAddress ?? null,
errorDetail: row.errorDetail ?? null,
};
}
const dek = await this.dek.getDek();
return {
...row,
variables: row.variables
? (JSON.parse(this.crypto.decrypt(row.variables, dek)) as Record<string, unknown>)
: {},
resolvedAddress: row.resolvedAddress ? this.crypto.decrypt(row.resolvedAddress, dek) : null,
errorDetail: row.errorDetail ? this.crypto.decrypt(row.errorDetail, dek) : null,
};
}
}
Every write path that persists these fields goes through encryptRow. Every read path that consumes the decrypted values goes through decryptRow. Lists that don't need decryption just skip it (defaulted-omit fields are already absent).
3. The Actual Changes¶
Schema migration¶
One new Prisma migration: change variables column type.
-- Migration: phi_encryption_notifications
ALTER TABLE notification MODIFY COLUMN variables LONGTEXT NULL;
resolvedAddress is already TEXT — base64 ciphertext for even long emails fits easily.
errorDetail is already TEXT — same.
Migration is safe even on non-empty tables: LONGTEXT is a wider superset of JSON column data at the storage level, and existing JSON values stay readable as strings.
Files created¶
services/notifications/src/crypto/
├── crypto.module.ts
├── dek-resolver.ts
├── dek-resolver.spec.ts
├── helper.ts
└── helper.spec.ts
Files modified¶
services/notifications/prisma/schema.prisma # variables: Json? → String? @db.LongText
services/notifications/prisma.config.ts # add omit: { notification: { variables, resolvedAddress, errorDetail } }
services/notifications/src/config/app-config.ts # add NOTIFICATIONS_WRAPPED_DEK + LOCAL_KEK_BASE64 + NOTIFICATIONS_KEY_PROVIDER
services/notifications/src/app.module.ts # register CryptoModule
services/notifications/src/notifications/notifications.service.ts
# - inject NotificationCryptoHelper
# - create() wraps data construction with encryptRow()
# - getById() opts in to all 3 fields, decryptRow() before return
# - remove TODO(phi) comment
services/notifications/src/events/event-consumer.service.ts
# - inject NotificationCryptoHelper
# - transactional notification.create wraps data construction with encryptRow()
# - remove TODO(phi) comment
services/notifications/src/delivery/worker.service.ts
# - inject NotificationCryptoHelper
# - process() opts in to all 3 fields on the load-by-id, decryptRow() before using
# - the variables field is typed as Record<string, unknown> post-decrypt (already is)
services/notifications/.env.example # add NOTIFICATIONS_WRAPPED_DEK, LOCAL_KEK_BASE64
Files regenerated¶
services/notifications/src/generated/prisma/ # gitignored; re-emitted
services/notifications/prisma/migrations/<timestamp>_phi_encryption_notifications/migration.sql
4. Verification Plan¶
Local verification sequence¶
pnpm install— no new deps (crypto primitives fromnode:crypto, reuses@sa-platform/common).- Generate dev DEK for local env if not set, and set env var. Document this in
.env.examplewith a known fixed dev value. pnpm --filter @sa-platform/notifications prisma:migrate:dev --name phi_encryption_notifications— apply the schema change to the local DB.pnpm --filter @sa-platform/notifications prisma:generate— regenerate client with omit config.pnpm turbo run typecheck— green.pnpm turbo run test— green. Some existing notifications tests will need updates:- Any test that creates a notification and inspects
variablesneeds to account for the new stringified format and thedecryptRowpath. - Worker tests need the encryption-helper mock or real instance.
- Integration tests verify ciphertext present in DB when
phi: true, plaintext absent. pnpm --filter @sa-platform/notifications test:integration— green. New scenarios:- Create a PHI notification, fetch via raw Prisma query, assert
variablesandresolvedAddresscolumns contain non-JSON ciphertext strings. - Create a non-PHI notification, fetch via raw Prisma query, assert
variablesis readable JSON string,resolvedAddressis plaintext. - Worker happy path with PHI notification — assert provider sends with decrypted values.
- Admin GET endpoint returns decrypted values for a PHI notification.
pnpm start:devsmoke — boot and hit/health/readywith and without the DEK env var.- With DEK: 200 as normal.
- Without DEK: service should still boot (encryption is lazy; first PHI send triggers the error) — verify a non-PHI send still works. A proper operational setup always has the DEK.
CI verification¶
- All CI jobs green.
Rollback plan¶
Revert the PR. The schema migration is backward-compatible (LONGTEXT accepts JSON-shaped strings); old code still reads existing rows. New rows written under this PR would become unreadable if reverted without a reverse migration — but the failure mode is clean (decrypt throws, no silent corruption). Given near-zero production volume, risk is minimal.
5. Risks¶
| Risk | Likelihood | Mitigation |
|---|---|---|
| DEK env var not set in prod → first PHI send fails | Medium | Service boots fine (lazy init). Startup warning if NOTIFICATIONS_WRAPPED_DEK is empty. Ops runbook must include DEK provisioning. |
| Existing unencrypted rows in dev/staging can't be decrypted after migration | Low | Forward-only: decryptRow checks phi boolean to decide whether to decrypt. Existing phi: true rows from pre-PR days (if any) become unreadable — acceptable given near-zero volume. |
| Test setup becomes awkward (tests must provide DEK) | Low | Integration test helper sets a fixed dev DEK in beforeAll. Unit tests mock the helper. |
JSON.parse on a corrupted decrypted string |
Low | Decrypt throws before parse if ciphertext is tampered (GCM auth tag detects). Parse errors surface as 500, which is correct for data corruption. |
prisma.config.ts omit config doesn't apply to raw SQL or $queryRaw |
Expected | These bypass omit — they're used for integration tests that WANT the ciphertext to assert storage format. Documented. |
| Performance: encrypt/decrypt on every PHI send adds ~1ms | Negligible | Negligible compared to SES round-trip. Not a v1 concern. |
| Key rotation | Deferred | KEK-wraps-DEK pattern supports it. Out of scope for this PR. |
6. Out of scope — follow-ups¶
- Backfill existing pre-encryption PHI rows (script, one-shot).
- KMS integration via
KmsKeyProvider(interface exists). - DEK rotation.
- Per-org DEKs (if multi-tenancy evolves to require separate encryption scopes).
- Applying the same pattern to
authservice's potential PHI columns (none currently). - Audit-log entry noting PHI send with hash of variables (compliance evidence).