Repository navigation
Strict tenant pack, catalog guard and REST isolation tests - #126
Merged
Merged
Conversation
howethomas
force-pushed
the
m12-strict-pack
branch
from
November 12, 2026 23:15
8dfe8f1 to
1d6be71
Compare
supabase/strict/ holds tier 2 of the tenant hardening as SQL that `supabase db push` does not apply. apply.sh runs strict_pack.sql and catalog_guard.sql in one transaction. - One policy per tenant table for authenticated, with no NULL branch; RLS enabled and forced. - get_current_tenant_id() reads the JWT tenant_id claim only. No claim, an empty claim and _quarantine mean no tenant. - vcon_tags_scoped filters the tag view to the caller's tenant. Tenant roles lose vcon_tags_mv and its refresh functions. - The twelve search and tag RPCs lose their NULL branch and read the scoped view. The tag-and-date search and its count gain a tenant predicate. A body the rewrite cannot place stops the run. - tenant_id NOT NULL and never empty on the ten tenant tables. - Composite keys: each child references its parent by (id, tenant_id), party_history through dialog (id, tenant_id). - authenticated loses vkong.vcons; the false embedding_queue policy goes. The pack refuses, changing nothing, while a tenant table holds a row with no tenant or a child whose tenant differs from its parent's. Every step checks the catalog first, so a second run changes nothing. The guard fails after an upgrade that brings back a NULL branch, a definer function, a view without security_invoker or a grant on a materialized view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/tenant-isolation/ starts PostgreSQL 17 with pgvector and PostgREST 16.2 at pool 4 in Docker, applies every migration unmodified, and loads synthetic tenants with a canary each: tenant A, tenant B, a tenant B vCon whose children have no tenant, a vCon with no tenant, and one in quarantine. - rest-shape.ts: requests as tenant A against the ten tables, the search and tag RPCs (including those behind the five lane tools), both views, the tag views, exec_sql, explain_query, the session-variable spoof, composite keys, every claim and token variant, and service-key searches and re-posts. A foreign canary fails; a positive read must show A's own. - --baseline runs the same requests on the unmodified schema and asserts the known leaks still show. - catalog-guard.ts tests apply.sh: refusal, dry run, apply, an identical catalog snapshot on a second run, upgrade regressions caught by the guard and repaired by a re-run, and composite keys on every child. scripts/test-tenant-isolation.ts now starts the REST shape: its old "authenticated" client used the service role key. The workflow runs on Node 24 with no npm install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/guide/strict-tenant-pack.md: applying the pack, re-running it after every upgrade, what it changes, and how to test it. - docs/guide/debugging-tenant-rls.md described the service role key as tenant-scoped, named migrations and functions that do not exist, and treated rows with no tenant as shared. Rewritten to what each setting does, with and without the pack. - src/db/tenant-context.ts and src/config/tenant-config.ts: comments only. The session variable never scoped service-key reads, and no database trigger reads the tenant attachment. - CHANGELOG Unreleased entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased on the writer and lane changes.
- The pack and the guard hold a list of service_role-only functions:
exec_sql, the tag refresh, the session-tenant and health functions,
both tenant backfills, vcon_tenant_stamp and
release_vcon_from_quarantine. The guard fails if anon or authenticated
can execute one; the pack takes the grant back.
- REST shape: re-posts now assert the stored tenant is kept (null,
omitted, another tenant, _quarantine), that a re-post cannot release a
quarantined vCon, that the writer functions and the release log refuse
a tenant and anon, and that release_vcon_from_quarantine moves a vCon
and its children under the composite keys.
- Under the pack, a re-post whose vCon row has no tenant_id fails with a
not-null error before the conflict branch; documented.
- stack.ts: startRest('lane') serves PostgREST as the lane needs it, a
login in authenticated only and no anon role. The strict run checks a
service_role token, an anon token and no token are refused there.
- Docs: SUMMARY entry, link from the lane guide, TENANT_MODE and the
writer functions in the debugging guide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
howethomas
force-pushed
the
m12-strict-pack
branch
from
November 12, 2026 23:29
1d6be71 to
8968672
Compare
Review fixes. - Row level security does not govern TRUNCATE: PUBLIC, anon and authenticated lose TRUNCATE, TRIGGER, REFERENCES and MAINTAIN on every table and view in public and vkong, and by default on tables postgres creates later. A SQL check runs SET ROLE authenticated; TRUNCATE on each table. - authenticated keeps SELECT and DELETE on the tenant tables and SELECT on views, and executes no VOLATILE function. That closes the uuid oracle (insert and save_vcon_batch answered differently for another tenant's uuid) and the squat (a tenant claiming a uuid before the writer stored it). The conversation_start refresh trigger runs as its owner, so a tenant's delete_vcon still removes the vCon and children. - The guard fails on those privileges, on a reachable VOLATILE function, and on a reachable function that treats the tenant with IS NULL, IS [NOT] DISTINCT FROM, COALESCE or NULLIF, or a comparison with ''. Each spelling is a planted case. It pins the owner of vcon_tags_scoped. - apply.sh takes the connection string from DATABASE_URL or --url-file and passes the password to psql in PGPASSWORD. - Guide: locks and time, the writer tools and REST routes that return nothing to a service key without a claim, and what the guard can and cannot see. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ascade Adds REST checks under the strict pack: tenant A's PATCH of updated_at, conversation_start, subject, created_at, tenant_id or uuid on its own vCon is refused, and A's delete of its own dialog row and then of the vCon removes them and every remaining child. The conversation_start refresh those deletes fire runs as its owner, so no UPDATE grant is needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard checked only that child_refresh_conversation_start() is SECURITY DEFINER. It now also requires its search_path to be exactly public, pg_temp, its owner to have BYPASSRLS (the refresh it runs as that owner depends on it), and no EXECUTE for anon or authenticated. The pack hands the function to the owner of vcons when its owner lacks BYPASSRLS, and refuses if that owner lacks it too. Planted cases, each failing the guard and repaired by the pack: the search_path removed, the search_path changed, the owner swapped to a role without BYPASSRLS, and EXECUTE re-granted to authenticated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the strict tenant pack: opt-in SQL that makes row-level security fail closed, a catalog guard that catches an upgrade undoing it, and a REST-level test harness on a throwaway stack.
What it adds
supabase/strict/:strict_pack.sql,catalog_guard.sqlandapply.sh.supabase db pushdoes not read this folder, so nothing changes for a deployment that does not run it.tests/tenant-isolation/: PostgreSQL 17 with pgvector and PostgREST 16.2 at pool 4 in Docker, every migration unmodified, HS256 JWTs, and synthetic tenants with a canary each.Tenant isolationworkflow with two jobs: strict (catalog guard, then REST shape) and baseline. No npm install; Node 24 runs the TypeScript directly.docs/guide/strict-tenant-pack.md, and corrections todocs/guide/debugging-tenant-rls.mdand two source comments.What the pack changes
authenticated, with no NULL branch. RLS forced.get_current_tenant_id()reads the JWTtenant_idclaim only. No claim, an empty claim and_quarantinemean no tenant. The session-variable fallback is gone.authenticatedkeepsSELECTandDELETEon the tenant tables andSELECTon views, and executes noVOLATILEfunction (the write RPCs, plus three read-only helpers declaredVOLATILE).PUBLIC,anonandauthenticatedloseTRUNCATE,TRIGGER,REFERENCESandMAINTAINon every table and view, also by default for tables created later.conversation_startafter a dialog or attachment delete runs as its owner, so a tenant'sdelete_vconstill works.vcon_tags_scoped, a tenant-filtered view overvcon_tags_mv, owned by the materialized view's owner. Tenant roles lose the materialized view and its refresh functions.search_vcons_by_tags_and_dateand its count gain a tenant predicate.tenant_idis NOT NULL and never empty on the ten tenant tables.(id, tenant_id).authenticatedlosesvkong.vcons. Service-only functions (exec_sql, the tag refresh, both tenant backfills,vcon_tenant_stamp,release_vcon_from_quarantineand others) are taken back if re-granted.The apply script refuses, changing nothing, while a tenant table holds a row with no tenant or a child whose tenant differs from its vCon's. A second run changes nothing. It takes the connection string from
DATABASE_URLor--url-fileand hands the password topsqlinPGPASSWORD.The catalog guard
It fails on all of the above being undone, and on a reachable function that treats the tenant in a NULL-tolerant way:
IS NULL,IS [NOT] DISTINCT FROM,COALESCEorNULLIFaround it, or a comparison with''. The guide says what it cannot see.Behaviour changes for a deployment that applies it
search_vcons_content,search_vcons_semantic,search_vcons_hybrid,search_by_tagsandvcon_aggregate;search_vconsandvcon_searchwith tags or a dealer filter;vcon_searchin keyword, semantic or hybrid mode;/vcons/search/content,/vcons/search/semantic,/vcons/search/hybrid,/tags/search, and/vconswith tags.search_vconsstill returns rows but reports a total of 0. Table reads,get_vcon,delete_vconand the write tools are unchanged.save_vcon_batchre-post whose vCon row has notenant_idfails with a not-null error.TENANT_MODE=writeralways stamps one.ACCESS EXCLUSIVElocks in one transaction. Apply it in a maintenance window. A store of a few hundred vCons takes well under a second.Results
Rebased on the writer (#125) and lane (#124) changes: 69 migrations, all applied unmodified.
node tests/tenant-isolation/rest-shape.tsnode tests/tenant-isolation/rest-shape.ts --baselinenode tests/tenant-isolation/catalog-guard.tsOn the unmodified schema the guard reports 129 violations. The baseline run shows the leaks the pack closes:
SET ROLE authenticated; TRUNCATEempties every table;Not in this PR
src/db/tenant-context.tsstill says queries return only NULL-tenant rows. Only comments were changed there; a comment above it says what is true.SET ROLE authenticatedcan stillLOCKa tenant table it may delete from. PostgREST never issuesLOCK. Listed in the guide's known limits.Test fixtures carry no
lawful_basisattachment, on purpose. They are synthetic.🤖 Generated with Claude Code