Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
52 changes: 52 additions & 0 deletions tests/supabase-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;",
);
});
});