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
12 changes: 6 additions & 6 deletions supabase/drift-manifest.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"generated_at": "2026-09-01T08:51:09.830Z",
"generated_at": "2026-09-01T12:38:48.655Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
"schema_sha256": "b3bbf9618572fc961d73fadaf5ba8b70aa794fc0357817a2e28781ff692166b1",
"schema_sha256": "1d0bc22c4e2fd9f2f45faa372c8dd958d55e785d17438920d436a295a9501875",
"replay_seconds": 18,
"snapshot": {
"views": [
Expand Down Expand Up @@ -8417,7 +8417,7 @@
"table": "document_labels"
},
{
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL",
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT",
"name": "document_labels_owner_id_fkey",
"table": "document_labels"
},
Expand Down Expand Up @@ -8542,7 +8542,7 @@
"table": "document_summaries"
},
{
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL",
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT",
"name": "document_summaries_owner_id_fkey",
"table": "document_summaries"
},
Expand All @@ -8557,7 +8557,7 @@
"table": "document_table_facts"
},
{
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL",
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT",
"name": "document_table_facts_owner_id_fkey",
"table": "document_table_facts"
},
Expand Down Expand Up @@ -8607,7 +8607,7 @@
"table": "documents"
},
{
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL",
"def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT",
"name": "documents_owner_id_fkey",
"table": "documents"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
-- Close the orphaned-document republication hazard (/issues #ZBAC9D).
--
-- A null `owner_id` independently means "public corpus" to retrieval, and these
-- owner foreign keys were `on delete set null`. Deleting an auth user therefore
-- converted that user's private rows into public ones, silently: the retrieval
-- predicates resolve the public sentinel to `row_owner_id is null` and check no
-- published marker.
--
-- Fix the foreign key rather than the predicate. `public.retrieval_owner_matches`
-- (and `..._v2`) are only unsafe because the FK can manufacture null owners; make
-- that impossible and "null owner = deliberately published" holds by construction.
-- This alters no query result, so there is no retrieval behaviour change and no
-- eval canary is required.
--
-- Scope is exactly the four tables whose OWN `owner_id` is passed to a retrieval
-- owner predicate, i.e. where a null owner means public:
-- public.documents (25 call sites across retrieval_owner_matches and _v2)
-- public.document_labels (1)
-- public.document_summaries (1)
-- public.document_table_facts (1)
-- Deliberately NOT included: document_sections, document_embedding_fields,
-- document_memory_cards and document_index_units are filtered through their parent
-- document's owner, never their own, so a null owner carries no visibility meaning
-- there. Nor are the retention tables (audit_logs, rag_queries, rag_retrieval_logs,
-- rag_query_misses, rag_answer_feedback, import_batches, storage_cleanup_jobs,
-- rag_visual_eval_cases, document_index_quality): for those, nulling the owner on
-- user deletion is deliberate retention behaviour and must be preserved.
--
-- Operational consequence, intended: deleting an auth user who still owns rows in
-- these tables now FAILS instead of orphaning them. Any account-deletion flow must
-- reassign or delete that user's documents first. Failing closed is the correct
-- posture for a clinical corpus.
--
-- Foreign-key validation only inspects non-null values. A read-only production
-- count on 2026-09-01 recorded 2851 documents with zero non-null `owner_id`, so
-- validation on public.documents is expected to be trivial. The same was not
-- separately measured for the other three tables; each is a child of documents and
-- is expected to be null-owned throughout, and any non-null value that does exist
-- must reference a live auth user for the constraint to be accepted. If validation
-- fails, that itself is a finding: it means a row references a deleted user.
--
-- Runs inside the single transaction the Supabase integration wraps each migration
-- in. `alter table ... drop constraint` / `add constraint` is fully transactional.

set local lock_timeout = '10s';
set local statement_timeout = '120s';

alter table public.documents
drop constraint documents_owner_id_fkey;
alter table public.documents
add constraint documents_owner_id_fkey
foreign key (owner_id) references auth.users(id) on delete restrict;

alter table public.document_labels
drop constraint document_labels_owner_id_fkey;
alter table public.document_labels
add constraint document_labels_owner_id_fkey
foreign key (owner_id) references auth.users(id) on delete restrict;

alter table public.document_summaries
drop constraint document_summaries_owner_id_fkey;
alter table public.document_summaries
add constraint document_summaries_owner_id_fkey
foreign key (owner_id) references auth.users(id) on delete restrict;

alter table public.document_table_facts
drop constraint document_table_facts_owner_id_fkey;
alter table public.document_table_facts
add constraint document_table_facts_owner_id_fkey
foreign key (owner_id) references auth.users(id) on delete restrict;

-- Fail fast if any of the four did not take, rather than recording a migration
-- whose statements did not achieve their effect (the #Q5JHBJ failure shape).
do $$
declare
wrong text[];
begin
select array_agg(c.conname order by c.conname)
into wrong
from pg_catalog.pg_constraint c
join pg_catalog.pg_class t on t.oid = c.conrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = 'public'
and c.contype = 'f'
and c.conname in (
'documents_owner_id_fkey',
'document_labels_owner_id_fkey',
'document_summaries_owner_id_fkey',
'document_table_facts_owner_id_fkey'
)
and c.confdeltype <> 'r'; -- 'r' = RESTRICT

if wrong is not null then
raise exception
'owner foreign keys still not ON DELETE RESTRICT: %', array_to_string(wrong, ', ');
end if;
end;
$$;
8 changes: 4 additions & 4 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ create table if not exists public.import_batches (

create table if not exists public.documents (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id) on delete set null,
owner_id uuid references auth.users(id) on delete restrict,
title text not null,
description text,
file_name text not null,
Expand Down Expand Up @@ -177,7 +177,7 @@ create table if not exists public.image_caption_cache (
create table if not exists public.document_labels (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
owner_id uuid references auth.users(id) on delete set null,
owner_id uuid references auth.users(id) on delete restrict,
label text not null,
label_type text not null
check (label_type in (
Expand Down Expand Up @@ -208,7 +208,7 @@ create table if not exists public.document_labels (
create table if not exists public.document_summaries (
id uuid primary key default gen_random_uuid(),
document_id uuid not null unique references public.documents(id) on delete cascade,
owner_id uuid references auth.users(id) on delete set null,
owner_id uuid references auth.users(id) on delete restrict,
summary text not null,
clinical_specifics jsonb not null default '{}'::jsonb,
source_chunk_ids uuid[] not null default '{}',
Expand Down Expand Up @@ -319,7 +319,7 @@ create table if not exists public.document_chunks (

create table if not exists public.document_table_facts (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id) on delete set null,
owner_id uuid references auth.users(id) on delete restrict,
document_id uuid not null references public.documents(id) on delete cascade,
source_chunk_id uuid references public.document_chunks(id) on delete cascade,
source_image_id uuid references public.document_images(id) on delete set null,
Expand Down
82 changes: 82 additions & 0 deletions tests/supabase-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1921,3 +1921,85 @@ describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () =>
}
});
});

describe("Owner deletion must not republish private rows (#ZBAC9D)", () => {
// A null `owner_id` independently means "public corpus" to retrieval:
// `retrieval_owner_matches` and `retrieval_owner_matches_v2` both resolve the
// public sentinel to `row_owner_id is null` and check no published marker. So
// for any table whose OWN owner_id reaches one of those predicates, an
// `on delete set null` foreign key lets deleting an auth user silently turn
// that user's private rows public. Those four tables must be `on delete
// restrict`, which makes the deletion fail instead.
const VISIBILITY_TABLES = ["documents", "document_labels", "document_summaries", "document_table_facts"] as const;

// Nulling the owner here is deliberate retention behaviour, NOT a visibility
// signal: these rows are either filtered through their parent document's owner
// or are audit/telemetry that must survive the account being removed. Widening
// the restrict set to them is a different, unreviewed decision.
const RETENTION_TABLES = [
"document_sections",
"document_embedding_fields",
"document_memory_cards",
"document_index_units",
"document_index_quality",
"import_batches",
"audit_logs",
"rag_queries",
"rag_query_misses",
"rag_retrieval_logs",
"rag_answer_feedback",
"rag_visual_eval_cases",
"storage_cleanup_jobs",
] as const;

const rawSchema = readFileSync(new URL("../supabase/schema.sql", import.meta.url), "utf8");

function ownerDeleteAction(table: string): string | null {
const start = rawSchema.search(new RegExp(String.raw`create table if not exists public\.${table}\s*\(`));
if (start < 0) return null;
const end = rawSchema.indexOf("\n);", start);
const block = rawSchema.slice(start, end);
const match = /owner_id uuid[^\n]*references auth\.users\(id\) on delete (set null|restrict|cascade)/.exec(block);
return match ? match[1] : null;
}

it.each(VISIBILITY_TABLES)(
"public.%s restricts owner deletion, so a deleted account cannot orphan rows into the public corpus",
(table) => {
expect(ownerDeleteAction(table)).toBe("restrict");
},
);

it.each(RETENTION_TABLES)(
"public.%s keeps its retention behaviour and is not swept into the restrict set",
(table) => {
expect(ownerDeleteAction(table)).not.toBe("restrict");
},
);

it("no other table has quietly joined the restrict set", () => {
const restricted = [...rawSchema.matchAll(/create table if not exists public\.([a-z0-9_]+)\s*\(/g)]
.map((match) => match[1])
.filter((table) => ownerDeleteAction(table) === "restrict");
expect(restricted.sort()).toEqual([...VISIBILITY_TABLES].sort());
});

it("ships the migration that applies the restrict action to live", () => {
const migration = readFileSync(
new URL(
"../supabase/migrations/20260901120000_restrict_owner_delete_on_public_visibility_tables.sql",
import.meta.url,
),
"utf8",
);
for (const table of VISIBILITY_TABLES) {
expect(migration).toContain(
`add constraint ${table}_owner_id_fkey\n foreign key (owner_id) references auth.users(id) on delete restrict;`,
);
}
// The migration must prove its own effect rather than trusting the recorded
// history — the #Q5JHBJ "statements never executed" shape.
expect(migration).toContain("c.confdeltype <> 'r'");
expect(migration).toContain("raise exception");
});
});
Loading