diff --git a/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql b/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql new file mode 100644 index 000000000..3c5a70d1e --- /dev/null +++ b/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql @@ -0,0 +1,42 @@ +-- v1.25 (W-DOCS-IN, security) — encrypt the staged extracted-fact payloads. +-- +-- The inbound-document store already encrypts the raw upload +-- (`inbound_documents.content_encrypted`) precisely because it is sensitive +-- PHI. The per-fact staging columns `extracted_facts.data_json` / +-- `provenance_json`, however, persisted the SAME class of data in the clear: +-- the FHIR-staged clinical values (diagnosis text, lab values, medication +-- names, stated codes) and the verbatim source span (up to 2000 chars copied +-- straight out of the discharge letter / lab report). That defeated the +-- at-rest guarantee for the feature. This migration converts both columns to +-- the AES-256-GCM `*_encrypted` BYTEA convention shared with the note columns; +-- the application encrypts on write and decrypts at confirm time. +-- +-- Transactional + fail-closed: encryption is application-layer, so the plaintext +-- JSONB cannot be re-encrypted in SQL. Staged facts are EPHEMERAL pre-confirm +-- (nothing committed until the user approves them) and this feature has not +-- shipped, so any rows staged by a pre-release build carry plaintext PHI we +-- will not retain — they are dropped here rather than left unencrypted, and +-- their parent document keeps its encrypted original and can be re-extracted. +-- The whole migration runs in one transaction, so a failure rolls back wholly. +-- +-- Reversibility (down) — re-add the plaintext columns (data is not recoverable): +-- ALTER TABLE "extracted_facts" ADD COLUMN "data_json" JSONB; +-- ALTER TABLE "extracted_facts" ADD COLUMN "provenance_json" JSONB; +-- ALTER TABLE "extracted_facts" DROP COLUMN "data_encrypted"; +-- ALTER TABLE "extracted_facts" DROP COLUMN "provenance_encrypted"; + +-- Add the encrypted replacements (nullable during the swap). +ALTER TABLE "extracted_facts" ADD COLUMN IF NOT EXISTS "data_encrypted" BYTEA; +ALTER TABLE "extracted_facts" ADD COLUMN IF NOT EXISTS "provenance_encrypted" BYTEA; + +-- Drop any pre-release staged rows: their plaintext payload cannot be +-- re-encrypted in SQL, and a pre-confirm fact carries no committed data. +DELETE FROM "extracted_facts" WHERE "data_encrypted" IS NULL; + +-- Enforce the at-rest guarantee: every staged fact now carries ciphertext. +ALTER TABLE "extracted_facts" ALTER COLUMN "data_encrypted" SET NOT NULL; +ALTER TABLE "extracted_facts" ALTER COLUMN "provenance_encrypted" SET NOT NULL; + +-- Drop the plaintext columns so no verbatim clinical span survives. +ALTER TABLE "extracted_facts" DROP COLUMN IF EXISTS "data_json"; +ALTER TABLE "extracted_facts" DROP COLUMN IF EXISTS "provenance_json"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f82e6ac2..67e260d4d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5621,11 +5621,17 @@ model ExtractedFact { /// The FHIR-staged fields, STATED status only, shaped per `factType` /// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10 /// / LOINC / RxNorm / ATC) are present only when the document writes them. - dataJson Json @map("data_json") + /// Stored encrypted: the JSON payload (diagnosis text, lab values, + /// medication names, stated codes) is clinical PHI transcribed verbatim from + /// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA + /// convention shared with the note columns rather than as plaintext JSONB. + dataEncrypted Bytes @map("data_encrypted") /// Per-field provenance: the verbatim source span the value came from, an /// optional page index, and per-field confidence. Makes "extract what's - /// written" auditable against the original. - provenanceJson Json @map("provenance_json") + /// written" auditable against the original. The source span is a verbatim + /// excerpt of the clinical document (PHI), so it is stored encrypted with the + /// same codec as `dataEncrypted`. + provenanceEncrypted Bytes @map("provenance_encrypted") /// The id of the structured-store row created when the fact was approved /// (LabResult / IllnessEpisode / Medication), or NULL while PENDING / /// REJECTED. diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 2308f6484..648fbb4f2 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -486,6 +486,22 @@ async function main() { prisma.inboundDocument, ), ); + // The staged extracted-fact payloads: the FHIR-staged clinical values and the + // verbatim source-span provenance. Both NOT NULL, so every staged row rotates. + results.push( + await rotateBytesColumn( + "ExtractedFact", + "dataEncrypted", + prisma.extractedFact, + ), + ); + results.push( + await rotateBytesColumn( + "ExtractedFact", + "provenanceEncrypted", + prisma.extractedFact, + ), + ); console.log("\n=== Rotation summary ==="); let totalRotated = 0; diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts index 533c88318..042b1387e 100644 --- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts +++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts @@ -17,8 +17,8 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { prisma, toJson } from "@/lib/db"; -import { serialiseFact } from "@/lib/documents/store"; +import { prisma } from "@/lib/db"; +import { encryptFactData, serialiseFact } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { @@ -113,7 +113,7 @@ export const PATCH = apiHandler( const updated = await prisma.extractedFact.update({ where: { id: fact.id }, data: { - dataJson: toJson(editToFactData(parsed.data)), + dataEncrypted: encryptFactData(editToFactData(parsed.data)), // The values are now user-asserted — the fail-closed gate is cleared. needsReview: false, }, diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index b301b06be..4b8fe1fc6 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -40,7 +40,7 @@ import { resolveDailyCap, } from "@/lib/ai/coach/budget"; import { auditLog } from "@/lib/auth/audit"; -import { prisma, toJson } from "@/lib/db"; +import { prisma } from "@/lib/db"; import { InboundExtractError, runInboundExtraction, @@ -48,6 +48,8 @@ import { } from "@/lib/documents/extract"; import { encryptDocumentToBytes, + encryptFactData, + encryptFactProvenance, serialiseDocument, } from "@/lib/documents/store"; import { @@ -108,8 +110,8 @@ async function persistExtraction(args: { status: "PENDING" as const, confidence: f.confidence, needsReview: f.needsReview, - dataJson: toJson(f.data), - provenanceJson: toJson(f.provenance), + dataEncrypted: encryptFactData(f.data), + provenanceEncrypted: encryptFactProvenance(f.provenance), })), }, }, diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 78ec9895a..db33126bb 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -179,6 +179,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // AES-256-GCM string → UTF-8 bytes. The most sensitive blob in the wave // (a full clinical document); never logged, never in wide-event meta. { model: "InboundDocument", field: "contentEncrypted", kind: "bytes" }, + // The staged extracted-fact payloads: the FHIR-staged clinical values + // (diagnosis text / lab values / medication names / stated codes) and the + // verbatim source-span provenance. Both are PHI transcribed from the source + // document, so the staging rows carry the same at-rest guarantee as the + // document itself rather than persisting as plaintext JSONB. + { model: "ExtractedFact", field: "dataEncrypted", kind: "bytes" }, + { model: "ExtractedFact", field: "provenanceEncrypted", kind: "bytes" }, ] as const; /** Stable `Model.field` key for a registry entry. */ diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 9c0fd952a..5431bed47 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -18,6 +18,7 @@ */ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { prisma } from "@/lib/db"; +import { decryptFactData } from "@/lib/documents/store"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import type { ExtractedFact } from "@/generated/prisma/client"; import type { @@ -152,20 +153,14 @@ export async function commitApprovedFact( userId: string, fact: ExtractedFact, ): Promise { + // Decrypt the staged payload at confirm time to write it into the normal + // structured store. Fail-closed: a bad key id throws and aborts the commit. + const data = decryptFactData(fact.dataEncrypted); if (fact.factType === "OBSERVATION") { - return commitObservation( - userId, - fact.dataJson as unknown as ObservationFactData, - ); + return commitObservation(userId, data as ObservationFactData); } if (fact.factType === "CONDITION") { - return commitCondition( - userId, - fact.dataJson as unknown as ConditionFactData, - ); + return commitCondition(userId, data as ConditionFactData); } - return commitMedication( - userId, - fact.dataJson as unknown as MedicationStatementFactData, - ); + return commitMedication(userId, data as MedicationStatementFactData); } diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index c05964c41..5701b7ad9 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -8,6 +8,7 @@ */ import { Buffer } from "node:buffer"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { decrypt, encrypt } from "@/lib/crypto"; import type { ExtractedFact, @@ -40,6 +41,37 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer { return Buffer.from(base64, "base64"); } +/** + * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the + * schema stores. The structured clinical values (diagnosis text, lab values, + * medication names, stated codes) are PHI, so they ride the shared AES-256-GCM + * note codec (JSON → `encrypt()` string → UTF-8 bytes) rather than plaintext + * JSONB. + */ +export function encryptFactData(data: FactData): Uint8Array { + return encryptToBytes(JSON.stringify(data)); +} + +/** Decrypt a staged fact's payload back to its DTO shape. Throws on bad key. */ +export function decryptFactData(buf: Uint8Array): FactData { + return JSON.parse(decryptFromBytes(buf)) as FactData; +} + +/** + * Encrypt a staged fact's provenance. The verbatim source span is a clinical + * document excerpt (PHI); encrypted with the same codec as the fact data. + */ +export function encryptFactProvenance( + provenance: FactProvenance, +): Uint8Array { + return encryptToBytes(JSON.stringify(provenance)); +} + +/** Decrypt a staged fact's provenance back to its DTO shape. */ +export function decryptFactProvenance(buf: Uint8Array): FactProvenance { + return JSON.parse(decryptFromBytes(buf)) as FactProvenance; +} + /** Map a persisted document row (+ counts) to the list/detail DTO. */ export function serialiseDocument( doc: InboundDocument, @@ -72,8 +104,8 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto { status: fact.status as ExtractedFactStatus, confidence: fact.confidence, needsReview: fact.needsReview, - data: fact.dataJson as unknown as FactData, - provenance: fact.provenanceJson as unknown as FactProvenance, + data: decryptFactData(fact.dataEncrypted), + provenance: decryptFactProvenance(fact.provenanceEncrypted), committedRecordId: fact.committedRecordId, committedRecordType: fact.committedRecordType, };