diff --git a/supabase/migrations/20260717120000_corrector_public_titles_only.sql b/supabase/migrations/20260717120000_corrector_public_titles_only.sql new file mode 100644 index 000000000..46d062920 --- /dev/null +++ b/supabase/migrations/20260717120000_corrector_public_titles_only.sql @@ -0,0 +1,87 @@ +-- Scope the clinical query-term corrector's vocabulary to the public corpus. +-- +-- correct_clinical_query_terms() is SECURITY DEFINER and therefore bypasses RLS. +-- It previously built its spell-correction vocabulary from EVERY indexed document +-- title AND every enabled rag_aliases row regardless of owner, while the rest of +-- retrieval is strictly owner-scoped and fail-closed (see +-- docs/tenancy-defense-in-depth-review.md and retrieval_owner_matches). Both +-- rag_aliases (owner_id column; deep-memory.ts persists owner-scoped aliases and +-- canonicals for private documents) and documents carry private rows, so the unscoped +-- reads let a private tenant's terms bias — and, via observable query rewriting, leak +-- the existence of — another tenant's private aliases/titles: a cross-tenant side-channel. +-- +-- Fix: restrict every vocabulary source (both alias selects and the title scan) to the +-- shared public corpus (owner_id is null). Signature is unchanged, so callers and +-- generated types need no update. Idempotent CREATE OR REPLACE. +-- +-- NOTE ON SEQUENCING: this migration is the forward change. supabase/schema.sql (the +-- canonical replay reference) and supabase/drift-manifest.json must be updated to +-- match when this migration is applied to the live project — both require the Docker +-- replay (`npm run drift:manifest`) that is unavailable in the CI/agent sandbox. Apply +-- this migration + the schema.sql mirror + the manifest regen together as one operator +-- step (live apply is confirmation-gated). Until applied, live == schema.sql == manifest +-- (all the pre-scope version), so no drift is introduced by merging this file alone. + +CREATE OR REPLACE FUNCTION public.correct_clinical_query_terms(input_query text, min_sim real DEFAULT 0.45) + RETURNS text + LANGUAGE plpgsql + STABLE SECURITY DEFINER + SET search_path TO 'public', 'extensions', 'pg_temp' +AS $function$ +declare + vocab text[]; + tokens text[]; + tok text; + best text; + best_sim real; + corrected text[] := array[]::text[]; + changed boolean := false; +begin + if input_query is null or length(trim(input_query)) = 0 then + return input_query; + end if; + + -- Build the known-term vocabulary once per call. Every source is scoped to the + -- public (null-owner) corpus: rag_aliases carries an owner_id (deep-memory persists + -- owner-scoped aliases/canonicals for private documents), so an unscoped alias read + -- would leak private-document-derived terms across tenants just like the title scan. + select array_agg(distinct term) into vocab + from ( + select lower(alias) as term from public.rag_aliases where enabled and owner_id is null and length(alias) between 4 and 40 + union + select lower(canonical) from public.rag_aliases where enabled and owner_id is null and length(canonical) between 4 and 40 + union + select w from public.documents d, lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as w + -- Public (null-owner) titles only: keep the correction vocabulary tenant-safe. + where d.status = 'indexed' and d.owner_id is null and length(w) between 4 and 40 + ) t; + + tokens := regexp_split_to_array(lower(input_query), '\s+'); + foreach tok in array tokens loop + if length(tok) < 4 or tok = any(vocab) then + corrected := corrected || tok; + continue; + end if; + best := null; + best_sim := 0; + select v, similarity(v, tok) into best, best_sim + from unnest(vocab) as v + order by similarity(v, tok) desc + limit 1; + if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then + corrected := corrected || best; + changed := true; + else + corrected := corrected || tok; + end if; + end loop; + + if not changed then + return input_query; + end if; + return array_to_string(corrected, ' '); +end; +$function$; + +revoke execute on function public.correct_clinical_query_terms(text, real) from public, anon, authenticated; +grant execute on function public.correct_clinical_query_terms(text, real) to service_role; diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 6de21304a..2e1b4634a 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1187,3 +1187,55 @@ describe("Supabase Preview replay guards", () => { expect(ingestionRpcPrivilegesDuplicateMigration).toContain("select 1 where false;"); }); }); + +describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => { + // The fix ships as a forward migration; schema.sql + drift-manifest are synced at + // the Docker-gated apply step, so this asserts the migration rather than schema.sql. + const correctorPublicTitlesMigration = readFileSync( + new URL("../supabase/migrations/20260717120000_corrector_public_titles_only.sql", import.meta.url), + "utf8", + ) + .replace(/\s+/g, " ") + .toLowerCase(); + + it("recreates the corrector scoped to the public (null-owner) title corpus", () => { + expect(correctorPublicTitlesMigration).toContain( + "create or replace function public.correct_clinical_query_terms(input_query text, min_sim real default 0.45)", + ); + // SECURITY DEFINER bypasses RLS, so the title scan must be owner-scoped to keep a + // private tenant's title tokens out of every caller's correction vocabulary. + expect(correctorPublicTitlesMigration).toContain( + "where d.status = 'indexed' and d.owner_id is null and length(w) between 4 and 40", + ); + // Regression guard: the old unscoped predicate must not survive in the migration. + expect(correctorPublicTitlesMigration).not.toContain("where d.status = 'indexed' and length(w) between 4 and 40"); + }); + + it("scopes the rag_aliases vocabulary sources to public (null-owner) rows", () => { + // rag_aliases carries an owner_id (deep-memory persists owner-scoped aliases for + // private documents), so both alias reads must be owner-scoped too — otherwise the + // title fix alone still leaks private-document-derived terms across tenants. + expect(correctorPublicTitlesMigration).toContain( + "select lower(alias) as term from public.rag_aliases where enabled and owner_id is null and length(alias) between 4 and 40", + ); + expect(correctorPublicTitlesMigration).toContain( + "select lower(canonical) from public.rag_aliases where enabled and owner_id is null and length(canonical) between 4 and 40", + ); + // Regression guard: the old unscoped alias reads must not survive. + expect(correctorPublicTitlesMigration).not.toContain( + "from public.rag_aliases where enabled and length(alias) between 4 and 40", + ); + expect(correctorPublicTitlesMigration).not.toContain( + "from public.rag_aliases where enabled and length(canonical) between 4 and 40", + ); + }); + + it("keeps the corrector execute privilege confined to service_role", () => { + expect(correctorPublicTitlesMigration).toContain( + "revoke execute on function public.correct_clinical_query_terms(text, real) from public, anon, authenticated;", + ); + expect(correctorPublicTitlesMigration).toContain( + "grant execute on function public.correct_clinical_query_terms(text, real) to service_role;", + ); + }); +});