diff --git a/.env.example b/.env.example index e56b6a601..cdf6c9c01 100644 --- a/.env.example +++ b/.env.example @@ -84,9 +84,13 @@ RAG_AWAIT_QUERY_LOGS=false #RAG_PERSIST_RAW_QUERY_TEXT=false # Server-side key for the redacted query-hash placeholder (min 16 chars). # When set, stored query hashes are HMAC-SHA256 keyed pseudonyms — not -# offline-reversible and not correlatable outside this deployment. Strongly -# recommended wherever real clinical queries are logged. Changing or setting -# the key changes future hashes, so historical dedup/joins reset from then on. +# offline-reversible and not correlatable outside this deployment. REQUIRED in +# production: instrumentation.register() calls requireQueryHashSecret() and the +# server refuses to start without it, so clinical-query hashes are never logged +# under the weak, reversible SHA-256 fallback (see docs/privacy-impact-assessment.md, +# PIA-2). Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Changing or setting the key changes future hashes, so historical dedup/joins +# reset from then on. Do NOT commit the real value. #RAG_QUERY_HASH_SECRET= # Private buckets created by supabase/schema.sql. diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 58c82ad0c..1b22a6ccd 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -16,7 +16,7 @@ export async function register() { throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); } - const { isDemoMode, requireOpenAIEnv, requireServerEnv } = await import("@/lib/env"); + const { isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireServerEnv } = await import("@/lib/env"); // A clinical production server must run against real, configured backends — never // in demo mode, which bypasses auth and serves canned content. @@ -30,4 +30,8 @@ export async function register() { // is missing or points at the wrong project, instead of failing per-request. requireServerEnv(); requireOpenAIEnv(); + + // A keyed HMAC secret must be present so clinical-query hashes written to the log + // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. + requireQueryHashSecret(); } diff --git a/src/lib/env.ts b/src/lib/env.ts index 19fcfcab4..d802d6657 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -185,6 +185,20 @@ export function requireOpenAIEnv() { } } +// Clinical query text is redacted to a keyed HMAC pseudonym before it is logged +// (see query-privacy.ts). Without RAG_QUERY_HASH_SECRET the hash silently degrades +// to an unsalted, dictionary-reversible SHA-256, which defeats the redaction: a +// reader of the log tables can hash candidate patient/drug strings offline and match +// rows. Production must fail closed rather than log real clinical queries under the +// weak digest. See docs/privacy-impact-assessment.md (PIA-2). +export function requireQueryHashSecret() { + if (!env.RAG_QUERY_HASH_SECRET) { + throw new Error( + "Missing RAG_QUERY_HASH_SECRET. It is required in production so logged clinical-query hashes are keyed HMAC-SHA256 pseudonyms, not offline-reversible SHA-256. Set a random secret (min 16 chars). See docs/privacy-impact-assessment.md (PIA-2).", + ); + } +} + export function isDemoMode() { // Explicit opt-in is honored in every environment (e.g. a deliberate demo deploy). if (env.NEXT_PUBLIC_DEMO_MODE === "true") { diff --git a/supabase/migrations/20260708120000_rag_query_misses_retention.sql b/supabase/migrations/20260708120000_rag_query_misses_retention.sql new file mode 100644 index 000000000..723020bcf --- /dev/null +++ b/supabase/migrations/20260708120000_rag_query_misses_retention.sql @@ -0,0 +1,55 @@ +-- PIA-4 (docs/privacy-impact-assessment.md): rag_query_misses stores the same +-- hash-redacted query + candidate aliases as rag_queries, but unlike rag_queries +-- (30d) and rag_retrieval_logs (90d) it had NO retention bound, so rows +-- accumulated indefinitely. Add a matching nightly purge (90 days — wider than +-- rag_queries to leave headroom for the miss-review workflow, still bounding +-- long-lived query telemetry). Query text is already hash-redacted at write time +-- via queryTextForStorage, so this bounds redacted telemetry, not raw PHI. +set search_path = public, pg_catalog, pg_temp; + +create or replace function public.purge_expired_rag_query_misses(p_retention_days integer default 90) +returns integer +language plpgsql +security definer +set search_path = public, pg_catalog, pg_temp +as $$ +declare + v_deleted integer; +begin + if p_retention_days < 1 then + raise exception 'retention days must be positive'; + end if; + delete from public.rag_query_misses where created_at < now() - make_interval(days => p_retention_days); + get diagnostics v_deleted = row_count; + return v_deleted; +end; +$$; + +revoke all on function public.purge_expired_rag_query_misses(integer) from public, anon, authenticated; +grant execute on function public.purge_expired_rag_query_misses(integer) to service_role; + +comment on table public.rag_query_misses is + 'Unresolved/low-confidence query telemetry (hash-redacted query + candidate aliases). + Rows older than 90 days are purged nightly by the pg_cron job + "purge-rag-query-misses" (see purge_expired_rag_query_misses). Adjust the window + by changing the interval in that job definition.'; + +-- Register the nightly purge when pg_cron is available. Preview/branch databases +-- may not ship pg_cron; install the function there but skip scheduling. +do $cron$ +begin + if to_regnamespace('cron') is null then + return; + end if; + + perform cron.unschedule(j.jobid) + from cron.job j + where j.jobname = 'purge-rag-query-misses'; + + perform cron.schedule( + 'purge-rag-query-misses', + '45 3 * * *', + $job$select public.purge_expired_rag_query_misses(90);$job$ + ); +end +$cron$; diff --git a/tests/env-query-hash-secret.test.ts b/tests/env-query-hash-secret.test.ts new file mode 100644 index 000000000..7acec12be --- /dev/null +++ b/tests/env-query-hash-secret.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// requireQueryHashSecret() reads the frozen `env` value parsed at import time, so +// each case re-imports the module with a stubbed environment. Production must fail +// closed when RAG_QUERY_HASH_SECRET is absent, so clinical-query hashes written to +// the log tables are keyed HMAC pseudonyms and not offline-reversible SHA-256 (PIA-2). + +async function loadEnv(secret: string | undefined) { + vi.resetModules(); + vi.stubEnv("RAG_QUERY_HASH_SECRET", secret); + return import("../src/lib/env"); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("requireQueryHashSecret", () => { + it("throws when RAG_QUERY_HASH_SECRET is absent", async () => { + const { requireQueryHashSecret } = await loadEnv(undefined); + expect(() => requireQueryHashSecret()).toThrow(/RAG_QUERY_HASH_SECRET/); + }); + + it("does not throw when RAG_QUERY_HASH_SECRET is set", async () => { + const { requireQueryHashSecret } = await loadEnv("test-secret-at-least-16-chars"); + expect(() => requireQueryHashSecret()).not.toThrow(); + }); +}); diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index fb47b1d3b..69b22ebee 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -17,6 +17,7 @@ const ENV_KEYS = [ "SUPABASE_PROJECT_REF", "SUPABASE_PROJECT_NAME", "OPENAI_API_KEY", + "RAG_QUERY_HASH_SECRET", "NEXT_PUBLIC_LOCAL_NO_AUTH", "LOCAL_NO_AUTH", ] as const; @@ -62,12 +63,23 @@ describe("instrumentation boot guard", () => { await expect(register()).rejects.toThrow(/OPENAI_API_KEY/); }); + it("refuses to start a production server without a query-hash secret", async () => { + const register = await loadRegister({ + ...PRODUCTION_NODE, + NEXT_PUBLIC_SUPABASE_URL: MATCHING_URL, + SUPABASE_SERVICE_ROLE_KEY: "service-role-key", + OPENAI_API_KEY: "openai-key", + }); + await expect(register()).rejects.toThrow(/RAG_QUERY_HASH_SECRET/); + }); + it("starts a fully configured production server", async () => { const register = await loadRegister({ ...PRODUCTION_NODE, NEXT_PUBLIC_SUPABASE_URL: MATCHING_URL, SUPABASE_SERVICE_ROLE_KEY: "service-role-key", OPENAI_API_KEY: "openai-key", + RAG_QUERY_HASH_SECRET: "test-secret-at-least-16-chars", }); await expect(register()).resolves.toBeUndefined(); });