From 066a0ea52c00545cbf3508f33a97e0aaac552bf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 05:10:56 +0000 Subject: [PATCH 1/2] fix(rag): scope query-term corrector to public titles via migration (F10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit correct_clinical_query_terms() is SECURITY DEFINER and bypasses RLS. It built its spell-correction vocabulary from every indexed document title regardless of owner, while the rest of retrieval is strictly owner-scoped and fail-closed (retrieval_owner_matches, docs/tenancy-defense-in-depth-review.md). The unscoped scan folded private tenants' title tokens into every caller's correction vocabulary — a cross-tenant existence side-channel for private title tokens (low severity: token existence, not content). Ship the fix as forward migration 20260717120000_corrector_public_titles_only.sql that recreates the function with the title union restricted to the public corpus (owner_id is null). RAG aliases are curated/global and unchanged; the signature is unchanged, so callers and generated types need no update. Adds migration-backed tests (public-only scope, regression guard against the old unscoped predicate, and service_role execute confinement). Migration-first on purpose: syncing supabase/schema.sql and regenerating supabase/drift-manifest.json need the Docker replay (npm run drift:manifest) that CI/the agent sandbox cannot run, so those are coupled to the live-apply step and performed together by an operator. The migration is NOT applied to the live project here (confirmation-gated); until applied, live == schema.sql == manifest, so merging this file introduces no drift. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FJpsbpsmjJ2tLLXFfSe9GK --- ...717120000_corrector_public_titles_only.sql | 82 +++++++++++++++++++ tests/supabase-schema.test.ts | 33 ++++++++ 2 files changed, 115 insertions(+) create mode 100644 supabase/migrations/20260717120000_corrector_public_titles_only.sql 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..05b018feb --- /dev/null +++ b/supabase/migrations/20260717120000_corrector_public_titles_only.sql @@ -0,0 +1,82 @@ +-- Scope the clinical query-term corrector's title 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 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). That let a private tenant's title tokens bias — and, +-- via observable query rewriting, leak the existence of — another tenant's private +-- titles: a cross-tenant side-channel. +-- +-- Fix: restrict the title union to the shared public corpus (owner_id is null). RAG +-- aliases are curated/global and remain unchanged. 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. + select array_agg(distinct term) into vocab + from ( + select lower(alias) as term from public.rag_aliases where enabled and length(alias) between 4 and 40 + union + select lower(canonical) from public.rag_aliases where enabled 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..512d083b0 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1187,3 +1187,36 @@ 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("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;", + ); + }); +}); From 983f951a8874245ced7cabea60d8ca58c8d64af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 05:51:11 +0000 Subject: [PATCH 2/2] fix(rag): also scope corrector alias vocabulary to public rows (F10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the corrector title scoping: the two rag_aliases reads in correct_clinical_query_terms were still unscoped. rag_aliases carries an owner_id (there is a rag_aliases_owner_enabled_idx), and deep-memory.ts persists owner-scoped aliases/canonicals when a private document is indexed (src/lib/deep-memory.ts). Because the function is SECURITY DEFINER and bypasses RLS, a caller in another tenant could still trigger and observe corrections toward private document-derived alias/canonical terms — the same cross-tenant side-channel the title filter closed, via aliases. Scope both alias selects to the public corpus (owner_id is null), matching the title scan and the public-titles-only decision. Extends the migration-backed test to assert the scoped alias reads and guard against the old unscoped form. Still migration-only (schema.sql/manifest sync coupled to the Docker-gated live-apply); schema.sql remains untouched so the drift manifest stays fresh. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FJpsbpsmjJ2tLLXFfSe9GK --- ...717120000_corrector_public_titles_only.sql | 29 +++++++++++-------- tests/supabase-schema.test.ts | 19 ++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/supabase/migrations/20260717120000_corrector_public_titles_only.sql b/supabase/migrations/20260717120000_corrector_public_titles_only.sql index 05b018feb..46d062920 100644 --- a/supabase/migrations/20260717120000_corrector_public_titles_only.sql +++ b/supabase/migrations/20260717120000_corrector_public_titles_only.sql @@ -1,16 +1,18 @@ --- Scope the clinical query-term corrector's title vocabulary to the public corpus. +-- 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 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). That let a private tenant's title tokens bias — and, --- via observable query rewriting, leak the existence of — another tenant's private --- titles: a cross-tenant side-channel. +-- 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 the title union to the shared public corpus (owner_id is null). RAG --- aliases are curated/global and remain unchanged. Signature is unchanged, so callers --- and generated types need no update. Idempotent CREATE OR REPLACE. +-- 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 @@ -39,12 +41,15 @@ begin return input_query; end if; - -- Build the known-term vocabulary once per call. + -- 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 length(alias) between 4 and 40 + 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 length(canonical) between 4 and 40 + 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. diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 512d083b0..2e1b4634a 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1211,6 +1211,25 @@ describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => 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;",