Skip to content

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 phi flag on the row and encrypt variables + resolvedAddress only when phi: 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 the LocalKeyProvider interface from @sa-platform/common).
  • Encrypt these fields on write when phi === true:
  • Notification.variables (JSON, stringified before encrypt)
  • Notification.resolvedAddress
  • Notification.errorDetail
  • Decrypt on read where needed:
  • Worker pipeline (rendering, provider send)
  • Admin GET /v1/notifications/:id endpoint (ops inspection)
  • Adopt Prisma 7 omit config to default-omit the three encrypted columns from every query. Callers that need them opt in per-query with omit: { <field>: false }.
  • Prisma schema: widen the three columns to accommodate ciphertext (base64 grows ~33%, plus IV and auth tag — Text / LongText as appropriate). variables is already Json?; we change it to String? @db.LongText to hold ciphertext strings. resolvedAddress and errorDetail are already Text.
  • New service wiring: KeyProvider module (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; KmsKeyProvider interface is already defined in @sa-platform/common for 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:authTag base64 format from CryptoService.encrypt) when phi === true.
  • Plaintext round-trip: worker renders templates correctly, admin GET returns decrypted values.
  • Prisma 7 omit config 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-GCM encrypt/decrypt primitive.
  • KeyProvider interface + LocalKeyProvider implementation.

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: whatever KeyProvider.wrapKey() produced. For LocalKeyProvider, this is an env-var-derived key encoded as base64.
  • NOTIFICATIONS_KEY_PROVIDER — defaults to local. Future values: kms.
  • LOCAL_KEK_BASE64 — raw KEK for LocalKeyProvider to 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

  1. pnpm install — no new deps (crypto primitives from node:crypto, reuses @sa-platform/common).
  2. Generate dev DEK for local env if not set, and set env var. Document this in .env.example with a known fixed dev value.
  3. pnpm --filter @sa-platform/notifications prisma:migrate:dev --name phi_encryption_notifications — apply the schema change to the local DB.
  4. pnpm --filter @sa-platform/notifications prisma:generate — regenerate client with omit config.
  5. pnpm turbo run typecheck — green.
  6. pnpm turbo run test — green. Some existing notifications tests will need updates:
  7. Any test that creates a notification and inspects variables needs to account for the new stringified format and the decryptRow path.
  8. Worker tests need the encryption-helper mock or real instance.
  9. Integration tests verify ciphertext present in DB when phi: true, plaintext absent.
  10. pnpm --filter @sa-platform/notifications test:integration — green. New scenarios:
  11. Create a PHI notification, fetch via raw Prisma query, assert variables and resolvedAddress columns contain non-JSON ciphertext strings.
  12. Create a non-PHI notification, fetch via raw Prisma query, assert variables is readable JSON string, resolvedAddress is plaintext.
  13. Worker happy path with PHI notification — assert provider sends with decrypted values.
  14. Admin GET endpoint returns decrypted values for a PHI notification.
  15. pnpm start:dev smoke — boot and hit /health/ready with and without the DEK env var.
  16. With DEK: 200 as normal.
  17. 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 auth service's potential PHI columns (none currently).
  • Audit-log entry noting PHI send with hash of variables (compliance evidence).