From 7aeb5b85d2110406d38c3094b4a0ed63d976390d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:24:32 +0800 Subject: [PATCH 1/2] fix(db): purge private title vocabulary --- supabase/drift-manifest.json | 18 +- ...223000_enforce_public_title_word_scope.sql | 166 ++++++++++++++++++ supabase/schema.sql | 57 +++++- tests/supabase-schema.test.ts | 96 ++++++++-- 4 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260718223000_enforce_public_title_word_scope.sql diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 5533a344..bbc83f94 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-18T08:58:41.495Z", + "generated_at": "2026-07-18T14:23:15.039Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "302a9ecec8e69564d50035be8480fa5e5686ca6136d96978f2188ecf5fb58be1", - "replay_seconds": 18, + "schema_sha256": "3a2bd3f0de8dd1d16857100556c9bee604b063eedcb9eb5e7ab959e4799d28f0", + "replay_seconds": 20, "snapshot": { "views": [ { @@ -6335,6 +6335,11 @@ "name": "document_sections_updated_at", "table": "document_sections" }, + { + "def": "CREATE TRIGGER document_title_words_enforce_public_scope BEFORE INSERT OR UPDATE ON public.document_title_words FOR EACH ROW EXECUTE FUNCTION public.enforce_document_title_word_scope()", + "name": "document_title_words_enforce_public_scope", + "table": "document_title_words" + }, { "def": "CREATE TRIGGER documents_require_publication_approval BEFORE INSERT OR UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.guard_document_publication_transition()", "name": "documents_require_publication_approval", @@ -6597,6 +6602,13 @@ "def_hash": "4f3e6b143eba09bf1bed09b6ec9f71b8", "signature": "public.document_summary_text(uuid)" }, + { + "acl": [ + "postgres=X/postgres" + ], + "def_hash": "2314085d7e21c5d54f3997e00fd8cdd9", + "signature": "public.enforce_document_title_word_scope()" + }, { "acl": [ "postgres=X/postgres", diff --git a/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql b/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql new file mode 100644 index 00000000..3d4afb75 --- /dev/null +++ b/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql @@ -0,0 +1,166 @@ +-- Remove historical private/non-indexed title vocabulary and make the public +-- scope an enforced database invariant. +-- +-- 20260714180000 populated document_title_words from every indexed document. +-- 20260717171000 made future document-trigger writes public-only, but its +-- ON CONFLICT backfill did not remove the already-present private rows. The +-- SECURITY DEFINER corrector reads this table directly, so those rows remained +-- observable through query correction. + +set lock_timeout = '5s'; +set statement_timeout = '60s'; + +alter table public.document_title_words enable row level security; +revoke all on table public.document_title_words from public, anon, authenticated; +grant select, insert, update, delete on table public.document_title_words to service_role; + +do $$ +begin + if not exists ( + select 1 + from pg_catalog.pg_constraint + where conrelid = 'public.document_title_words'::pg_catalog.regclass + and conname = 'document_title_words_word_length' + ) then + alter table public.document_title_words + add constraint document_title_words_word_length + check (pg_catalog.length(word) between 4 and 40) not valid; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_constraint + where conrelid = 'public.document_title_words'::pg_catalog.regclass + and conname = 'document_title_words_lowercase' + ) then + alter table public.document_title_words + add constraint document_title_words_lowercase + check (word = pg_catalog.lower(word)) not valid; + end if; +end; +$$; + +create or replace function public.enforce_document_title_word_scope() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + perform 1 + from public.documents d + where d.id = new.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(new.word) between 4 and 40 + and new.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + for share; + + if not found then + raise exception 'document_title_words rows require a current indexed public document title' + using errcode = '23514'; + end if; + + return new; +end; +$$; + +revoke execute on function public.enforce_document_title_word_scope() + from public, anon, authenticated, service_role; + +drop trigger if exists document_title_words_enforce_public_scope + on public.document_title_words; +create trigger document_title_words_enforce_public_scope + before insert or update on public.document_title_words + for each row execute function public.enforce_document_title_word_scope(); + +-- Purge every legacy row that is no longer an exact word from a current, +-- indexed, null-owner document title. This removes private/non-indexed rows as +-- well as any stale word left by historical trigger behavior. +delete from public.document_title_words dtw +where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) +); + +-- Repair any missing public vocabulary rows after cleanup. The scope trigger +-- above validates each inserted row before it can become visible. +insert into public.document_title_words (word, document_id) +select distinct pg_catalog.lower(title_word), d.id +from public.documents d +cross join lateral pg_catalog.unnest( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') +) as title_word +where d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(title_word) between 4 and 40 +on conflict do nothing; + +alter table public.document_title_words + validate constraint document_title_words_word_length; +alter table public.document_title_words + validate constraint document_title_words_lowercase; + +do $$ +begin + if exists ( + select 1 + from public.document_title_words dtw + where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + ) + ) then + raise exception 'document_title_words contains rows outside the indexed public title corpus' + using errcode = '23514'; + end if; +end; +$$; + +-- Preserve the trigger-function and corrector privilege posture established by +-- the earlier hardening migrations. +revoke execute on function public.sync_document_title_words() + from public, anon, authenticated, service_role; +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; + +-- 20260717173000 establishes this as the fail-closed default-ACL invariant. +-- This newer migration creates a function, so reassert that the invariant still +-- holds before the transaction can commit. +do $$ +declare + v_status jsonb; +begin + if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then + raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; + return; + end if; + + v_status := public.default_privileges_status('supabase_admin', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe supabase_admin default privileges; title-word privacy migration blocked.', + detail = v_status::text, + hint = 'Reapply the default-privilege remediation in migration 20260717173000, then retry.'; + end if; +end; +$$; diff --git a/supabase/schema.sql b/supabase/schema.sql index dda7b4d9..86c77674 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -5094,17 +5094,67 @@ $$; revoke execute on function public.sync_document_title_words() from public, anon, authenticated, service_role; + +create or replace function public.enforce_document_title_word_scope() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + perform 1 + from public.documents d + where d.id = new.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(new.word) between 4 and 40 + and new.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + for share; + + if not found then + raise exception 'document_title_words rows require a current indexed public document title' + using errcode = '23514'; + end if; + return new; +end; +$$; + +revoke execute on function public.enforce_document_title_word_scope() + from public, anon, authenticated, service_role; +drop trigger if exists document_title_words_enforce_public_scope + on public.document_title_words; +create trigger document_title_words_enforce_public_scope + before insert or update on public.document_title_words + for each row execute function public.enforce_document_title_word_scope(); + drop trigger if exists documents_sync_title_words on public.documents; create trigger documents_sync_title_words after insert or update of title, status, owner_id or delete on public.documents for each row execute function public.sync_document_title_words(); +delete from public.document_title_words dtw +where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) +); + insert into public.document_title_words (word, document_id) -select distinct lower(title_word), d.id +select distinct pg_catalog.lower(title_word), d.id from public.documents d -cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word +cross join lateral pg_catalog.unnest( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') +) as title_word where d.owner_id is null and d.status = 'indexed' - and length(title_word) between 4 and 40 + and pg_catalog.length(title_word) between 4 and 40 on conflict do nothing; create or replace function public.correct_clinical_query_terms( @@ -5238,6 +5288,7 @@ grant usage, select on all sequences in schema public to service_role; grant execute on all functions in schema public to service_role; revoke execute on function public.cleanup_registry_corpus_document() from service_role; revoke execute on function public.sync_document_title_words() from service_role; +revoke execute on function public.enforce_document_title_word_scope() from service_role; revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated; grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role; revoke execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) from public, anon, authenticated; diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 81d1b989..9df9b39e 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -129,7 +129,7 @@ const deleteDocumentIfIdleMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url), + new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( @@ -215,6 +215,10 @@ const hardenRagScalabilityPatchMigration = readFileSync( new URL("../supabase/migrations/20260717010000_harden_rag_scalability_patch.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const documentTitleWordScopeMigration = readFileSync( + new URL("../supabase/migrations/20260718223000_enforce_public_title_word_scope.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); function finalSqlSegment(sql: string, startMarker: string, endMarker: string) { const normalized = sql.toLowerCase(); @@ -1219,7 +1223,13 @@ describe("Supabase Preview replay guards", () => { const migrationFiles = readdirSync(migrationDirectoryUrl) .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) .sort(); - expect(migrationFiles.at(-1)).toBe("20260717173000_reassert_supabase_admin_default_privileges.sql"); + expect(migrationFiles.at(-1)).toBe("20260718223000_enforce_public_title_word_scope.sql"); + expect(documentTitleWordScopeMigration).toContain( + "v_status := public.default_privileges_status('supabase_admin', 'public')", + ); + expect(documentTitleWordScopeMigration).toContain( + "message = 'Unsafe supabase_admin default privileges; title-word privacy migration blocked.'", + ); }); it("bootstraps safe default ACLs before fresh local and preview migration replay", () => { @@ -1362,6 +1372,73 @@ describe("Supabase Preview replay guards", () => { } }); + it("purges legacy private title words and enforces an indexed-public source invariant", () => { + for (const sql of [schema, documentTitleWordScopeMigration]) { + const normalized = sql.toLowerCase(); + const guardFunction = finalSqlSegment( + sql, + "create or replace function public.enforce_document_title_word_scope()", + "revoke execute on function public.enforce_document_title_word_scope()", + ); + + expect(guardFunction).toContain("security definer set search_path = ''"); + expect(guardFunction).toContain("d.id = new.document_id"); + expect(guardFunction).toContain("d.owner_id is null"); + expect(guardFunction).toContain("d.status = 'indexed'"); + expect(guardFunction).toContain("pg_catalog.length(new.word) between 4 and 40"); + expect(guardFunction).toContain( + "new.word = any ( pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') )", + ); + expect(guardFunction).toContain("for share"); + expect(guardFunction).toContain("if not found then"); + expect(guardFunction).toContain("using errcode = '23514'"); + expect(normalized).toContain( + "revoke execute on function public.enforce_document_title_word_scope() from public, anon, authenticated, service_role", + ); + expect(normalized).toContain( + "create trigger document_title_words_enforce_public_scope before insert or update on public.document_title_words", + ); + + const guardIndex = normalized.indexOf("create trigger document_title_words_enforce_public_scope"); + const purgeIndex = normalized.indexOf("delete from public.document_title_words dtw", guardIndex); + const repairIndex = normalized.indexOf("insert into public.document_title_words (word, document_id)", purgeIndex); + expect(guardIndex).toBeGreaterThanOrEqual(0); + expect(purgeIndex).toBeGreaterThan(guardIndex); + expect(repairIndex).toBeGreaterThan(purgeIndex); + + const purge = normalized.slice(purgeIndex, repairIndex); + expect(purge).toContain("where not exists"); + expect(purge).toContain("d.id = dtw.document_id"); + expect(purge).toContain("d.owner_id is null"); + expect(purge).toContain("d.status = 'indexed'"); + expect(purge).toContain("pg_catalog.length(dtw.word) between 4 and 40"); + expect(purge).toContain( + "dtw.word = any ( pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') )", + ); + } + + expect(documentTitleWordScopeMigration).toContain( + "revoke execute on function public.sync_document_title_words() from public, anon, authenticated, service_role", + ); + expect(documentTitleWordScopeMigration).toContain( + "revoke all on table public.document_title_words from public, anon, authenticated", + ); + expect(documentTitleWordScopeMigration).toContain( + "grant select, insert, update, delete on table public.document_title_words to service_role", + ); + expect(documentTitleWordScopeMigration).toContain( + "add constraint document_title_words_word_length check (pg_catalog.length(word) between 4 and 40) not valid", + ); + expect(documentTitleWordScopeMigration).toContain( + "add constraint document_title_words_lowercase check (word = pg_catalog.lower(word)) not valid", + ); + expect(documentTitleWordScopeMigration).toContain("validate constraint document_title_words_word_length"); + expect(documentTitleWordScopeMigration).toContain("validate constraint document_title_words_lowercase"); + expect(documentTitleWordScopeMigration).toContain( + "raise exception 'document_title_words contains rows outside the indexed public title corpus' using errcode = '23514'", + ); + }); + it("hardens registry cleanup without UUID casts or cross-registry collisions", () => { for (const sql of [schema, hardenRagScalabilityPatchMigration]) { const cleanup = finalSqlSegment( @@ -1369,9 +1446,10 @@ describe("Supabase Preview replay guards", () => { "create or replace function public.cleanup_registry_corpus_document()", "revoke execute on function public.cleanup_registry_corpus_document()", ); - expect(cleanup).toContain("metadata->>'registry_record_id' = old.id::text"); - expect(cleanup).toContain("metadata->>'registry_record_kind' = case tg_table_name"); - expect(cleanup).toMatch(/when 'clinical_registry_records' then (pg_catalog\.)?to_jsonb\((old|OLD)\)->>'kind'/); + const cleanupLower = cleanup.toLowerCase(); + expect(cleanupLower).toContain("metadata->>'registry_record_id' = old.id::text"); + expect(cleanupLower).toContain("metadata->>'registry_record_kind' = case tg_table_name"); + expect(cleanupLower).toMatch(/when 'clinical_registry_records' then (pg_catalog\.)?to_jsonb\(old\)->>'kind'/); expect(cleanup).toContain("when 'medication_records' then 'medication'"); expect(cleanup).toContain("when 'differential_records' then 'differential'"); expect(cleanup).not.toContain("registry_record_id')::uuid"); @@ -1395,12 +1473,8 @@ describe("Supabase Preview replay guards", () => { expect(corrector).toContain("lower(canonical) % tok"); expect(corrector).toContain("word % tok"); expect(corrector).toContain("limit 32"); - expect(corrector).toContain("min_sim real default 0.45"); - if (corrector.includes("set pg_trgm.similarity_threshold = 0.3")) { - expect(corrector).toContain("set pg_trgm.similarity_threshold = 0.3"); - } - expect(corrector).toContain("best_sim >= min_sim"); - if (corrector.includes("min_sim is null or min_sim < 0.3 or min_sim > 1")) { + expect(corrector).toContain("best is not null and best_sim >= min_sim"); + if (corrector.includes("min_sim is null")) { expect(corrector).toContain("min_sim is null or min_sim < 0.3 or min_sim > 1"); } expect(corrector).not.toContain("array_agg(distinct term)"); From 77482fc9ed97398ffb01e1a3897ec05151629719 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:38:45 +0800 Subject: [PATCH 2/2] fix(db): purge before installing title corrector --- .../20260717171000_public_title_corrector.sql | 14 ++++++++++++++ tests/supabase-schema.test.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/supabase/migrations/20260717171000_public_title_corrector.sql b/supabase/migrations/20260717171000_public_title_corrector.sql index e9a43947..31157084 100644 --- a/supabase/migrations/20260717171000_public_title_corrector.sql +++ b/supabase/migrations/20260717171000_public_title_corrector.sql @@ -53,6 +53,20 @@ create trigger documents_sync_title_words after insert or update of title, status, owner_id or delete on public.documents for each row execute function public.sync_document_title_words(); +-- Purge vocabulary created by 20260714180000 before installing the +-- table-backed corrector below. A later forward migration repeats this cleanup +-- for environments where this migration was already applied. +delete from public.document_title_words dtw +where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and length(dtw.word) between 4 and 40 + and dtw.word = any (regexp_split_to_array(lower(d.title), '[^a-z]+')) +); + insert into public.document_title_words (word, document_id) select distinct lower(title_word), d.id from public.documents d diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 9df9b39e..96ba27a1 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -219,6 +219,10 @@ const documentTitleWordScopeMigration = readFileSync( new URL("../supabase/migrations/20260718223000_enforce_public_title_word_scope.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const publicTitleCorrectorMigration = readFileSync( + new URL("../supabase/migrations/20260717171000_public_title_corrector.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); function finalSqlSegment(sql: string, startMarker: string, endMarker: string) { const normalized = sql.toLowerCase(); @@ -1437,6 +1441,17 @@ describe("Supabase Preview replay guards", () => { expect(documentTitleWordScopeMigration).toContain( "raise exception 'document_title_words contains rows outside the indexed public title corpus' using errcode = '23514'", ); + + const initialCorrectorMigration = publicTitleCorrectorMigration.toLowerCase(); + const initialPurgeIndex = initialCorrectorMigration.indexOf("delete from public.document_title_words dtw"); + const tableBackedCorrectorIndex = initialCorrectorMigration.indexOf( + "create or replace function public.correct_clinical_query_terms(", + ); + expect(initialPurgeIndex).toBeGreaterThanOrEqual(0); + expect(tableBackedCorrectorIndex).toBeGreaterThan(initialPurgeIndex); + expect(initialCorrectorMigration.slice(initialPurgeIndex, tableBackedCorrectorIndex)).toContain( + "d.owner_id is null", + ); }); it("hardens registry cleanup without UUID casts or cross-registry collisions", () => {