Skip to content

Strict tenant pack, catalog guard and REST isolation tests - #126

Merged
howethomas merged 7 commits into
mainfrom
m12-strict-pack
Nov 13, 2026
Merged

howethomas merged 7 commits into
mainfrom
m12-strict-pack

Conversation

@howethomas

@howethomas howethomas commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

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.sql and apply.sh. supabase db push does 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.
  • A Tenant isolation workflow 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 to docs/guide/debugging-tenant-rls.md and two source comments.

What the pack changes

  • One policy per tenant table for authenticated, with no NULL branch. RLS forced.
  • get_current_tenant_id() reads the JWT tenant_id claim only. No claim, an empty claim and _quarantine mean no tenant. The session-variable fallback is gone.
  • Tenant roles read and delete only. authenticated keeps SELECT and DELETE on the tenant tables and SELECT on views, and executes no VOLATILE function (the write RPCs, plus three read-only helpers declared VOLATILE).
  • PUBLIC, anon and authenticated lose TRUNCATE, TRIGGER, REFERENCES and MAINTAIN on every table and view, also by default for tables created later.
  • The trigger that refreshes conversation_start after a dialog or attachment delete runs as its owner, so a tenant's delete_vcon still works.
  • vcon_tags_scoped, a tenant-filtered view over vcon_tags_mv, owned by the materialized view's owner. Tenant roles lose the materialized view and its refresh functions.
  • Twelve search and tag RPCs lose their NULL branch and read the scoped view. search_vcons_by_tags_and_date and its count gain a tenant predicate.
  • tenant_id is NOT NULL and never empty on the ten tenant tables.
  • Composite keys: each child references its parent by (id, tenant_id).
  • authenticated loses vkong.vcons. Service-only functions (exec_sql, the tag refresh, both tenant backfills, vcon_tenant_stamp, release_vcon_from_quarantine and 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_URL or --url-file and hands the password to psql in PGPASSWORD.

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, COALESCE or NULLIF around it, or a comparison with ''. The guide says what it cannot see.

Behaviour changes for a deployment that applies it

  • With the service key and no tenant claim, these return nothing:
    • MCP search_vcons_content, search_vcons_semantic, search_vcons_hybrid, search_by_tags and vcon_aggregate;
    • search_vcons and vcon_search with tags or a dealer filter;
    • vcon_search in keyword, semantic or hybrid mode;
    • REST /vcons/search/content, /vcons/search/semantic, /vcons/search/hybrid, /tags/search, and /vcons with tags.
  • With dates and no subject, search_vcons still returns rows but reports a total of 0. Table reads, get_vcon, delete_vcon and the write tools are unchanged.
  • No row can be written without a tenant, including by the service key. A save_vcon_batch re-post whose vCon row has no tenant_id fails with a not-null error. TENANT_MODE=writer always stamps one.
  • The first apply scans every tenant table under ACCESS EXCLUSIVE locks 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.

Command Result
node tests/tenant-isolation/rest-shape.ts 311 passed, 0 failed
node tests/tenant-isolation/rest-shape.ts --baseline 302 passed, 0 failed, 146 known leaks reproduced
node tests/tenant-isolation/catalog-guard.ts 155 passed, 0 failed

On the unmodified schema the guard reports 129 violations. The baseline run shows the leaks the pack closes:

  • SET ROLE authenticated; TRUNCATE empties every table;
  • a tenant's insert of another tenant's uuid answers 23505 instead of success;
  • a tenant can claim a uuid that the writer later fills with another tenant's content.

Not in this PR

  • The log text in src/db/tenant-context.ts still says queries return only NULL-tenant rows. Only comments were changed there; a comment above it says what is true.
  • A database session that can SET ROLE authenticated can still LOCK a tenant table it may delete from. PostgREST never issues LOCK. Listed in the guide's known limits.

Test fixtures carry no lawful_basis attachment, on purpose. They are synthetic.

🤖 Generated with Claude Code

howethomas and others added 4 commits November 12, 2026 18:22
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 and others added 3 commits November 12, 2026 18:54
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>
@howethomas
howethomas merged commit 29efd07 into main Nov 13, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant