4.5 · Data Model and Migrations

4.5 · 30 min

Data Model and Migrations

Here is how agent-built SaaS leaks customer data, and it is almost never dramatic. An agent writes a dashboard query: select * from deliverables where project_id = $1. It works. It passes review, because the reviewer is checking whether the dashboard shows deliverables, and it does. Then a client in agency B guesses (or gets handed) a project id from agency A, and your database, asked politely, returns agency A's files. No exploit, no injection, just a missing where org_id = ... that nobody's eye caught because the happy path was flawless. This lesson builds the wall that makes that query fail closed: row level security, written and, more importantly, tested by attacking it.

The multi-tenancy decision, defended

ADR-001 committed to a shared schema with org_id on every tenant row and RLS enforcing isolation. The alternative was a schema or database per tenant, which gives you structural isolation (agency B's data is not even in the same namespace) at a cost that compounds with tenant count: every migration fans out across every tenant, cross-tenant admin queries become impossible, and connection pooling gets complicated. For a solo operator at hundreds of tenants, that ops bill is paid forever. Shared schema puts the isolation burden on RLS policies instead, which is a smaller, testable surface. The word testable is doing the work: the leak risk shared-schema introduces is real, and the entire back half of this lesson is the test suite that neutralizes it. An isolation mechanism you cannot attack in a test is not a decision, it is a hope.

Migrations are the only schema path

Two rules, both from the current Supabase migration workflow, both non-negotiable. First, every schema change is a migration file, applied with the CLI, committed to the repo; nobody clicks "add column" in a dashboard, because a schema that exists only in a running database is a schema you cannot reproduce, review, or roll back. Second, a committed migration is immutable: you never edit one that has been applied, you write a new one that alters. The reason is the same reason you never rewrite published git history: other environments (CI, production, a teammate's machine) have already applied the old version, and editing it forks reality.

terminalbash
supabase migration new create_core_schema
# writes supabase/migrations/<timestamp>_create_core_schema.sql
# edit the file, then apply to your local stack:
supabase db reset   # replays every migration + seed from scratch
# a clean reset is itself a test: if it fails, a migration is wrong

The schema

This is the full v1 schema from the TECH-SPEC, as one migration. It is valid PostgreSQL for Supabase's Postgres; the enums, the org_id on every tenant table, and the append-only intent of approvals all trace to 4.3.

supabase/migrations/0001_create_core_schema.sqlsql
-- Enums
create type member_role as enum ('owner', 'admin', 'member', 'client');
create type project_status as enum ('active', 'archived');
create type deliverable_status as enum
('draft', 'in_review', 'approved', 'changes_requested');
create type approval_decision as enum ('approved', 'changes_requested');

-- Orgs (tenants)
create table orgs (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
created_at timestamptz not null default now()
);

-- Membership: which user is what, in which org
create table org_members (
org_id uuid not null references orgs (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
role member_role not null,
created_at timestamptz not null default now(),
primary key (org_id, user_id)
);

create table projects (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs (id) on delete cascade,
name text not null,
status project_status not null default 'active',
created_at timestamptz not null default now()
);

-- Client visibility grants (staff need no rows here)
create table project_access (
project_id uuid not null references projects (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
created_at timestamptz not null default now(),
primary key (project_id, user_id)
);

create table deliverables (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs (id) on delete cascade,
project_id uuid not null references projects (id) on delete cascade,
title text not null,
file_path text,
version integer not null default 1,
status deliverable_status not null default 'draft',
created_by uuid not null references auth.users (id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create table comments (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs (id) on delete cascade,
deliverable_id uuid not null
  references deliverables (id) on delete cascade,
author_id uuid not null references auth.users (id),
body text not null,
created_at timestamptz not null default now()
);

-- Approvals are append-only: no update, no delete policy exists.
create table approvals (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs (id) on delete cascade,
deliverable_id uuid not null
  references deliverables (id) on delete cascade,
decided_by uuid not null references auth.users (id),
decision approval_decision not null,
note text,
created_at timestamptz not null default now()
);

-- Helpful indexes for the tenancy filters (org_id is hit constantly)
create index on projects (org_id);
create index on deliverables (org_id, project_id);
create index on comments (org_id, deliverable_id);
create index on approvals (org_id, deliverable_id);
create index on project_access (user_id);

Membership helpers, in a private schema

The policies need two questions answered constantly: is the current user a staff member of this org, and can this user see this project? Answering them inline in every policy means repeating a subquery a dozen times and getting it subtly different each time. The Supabase-recommended move is security definer functions in a schema that is not exposed through the API, so they can read the membership tables without triggering RLS recursion, and are wrapped in select in policies so the optimizer caches them per statement.

supabase/migrations/0002_auth_helpers.sqlsql
create schema if not exists private;

-- Is the current user staff (owner/admin/member) of this org?
create function private.is_org_staff(target_org uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
  select 1
  from public.org_members m
  where m.org_id = target_org
    and m.user_id = (select auth.uid())
    and m.role in ('owner', 'admin', 'member')
);
$$;

-- Is the current user an owner/admin (billing + member management)?
create function private.is_org_admin(target_org uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
  select 1
  from public.org_members m
  where m.org_id = target_org
    and m.user_id = (select auth.uid())
    and m.role in ('owner', 'admin')
);
$$;

-- Can the current user see this project? Staff see all org projects;
-- clients see only projects granted via project_access.
create function private.can_see_project(target_project uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
  select 1
  from public.projects p
  where p.id = target_project
    and (
      private.is_org_staff(p.org_id)
      or exists (
        select 1
        from public.project_access pa
        where pa.project_id = p.id
          and pa.user_id = (select auth.uid())
      )
    )
);
$$;

security definer functions run with the privileges of their creator, so they read org_members without recursing back into that table's own policies, which is what breaks the otherwise-circular "to check the members policy, check the members policy" problem. They live in private, never exposed through the Data API. And set search_path = '' forces every reference to be schema-qualified (public.org_members), closing a real privilege-escalation vector where a caller with a crafted search_path could shadow a table.

The policies

Now the wall. Every tenant table gets RLS enabled and policies that compile the 4.3 permissions matrix into the database. Each policy specifies to authenticated (so it never even runs for anonymous requests, per the performance guidance) and wraps auth.uid() in a select.

supabase/migrations/0003_rls_policies.sqlsql
alter table orgs enable row level security;
alter table org_members enable row level security;
alter table projects enable row level security;
alter table project_access enable row level security;
alter table deliverables enable row level security;
alter table comments enable row level security;
alter table approvals enable row level security;

-- ORGS: any member reads their org; only admins rename it.
create policy orgs_select on orgs
for select to authenticated
using (private.is_org_staff(id)
  or exists (select 1 from org_members m
    where m.org_id = orgs.id and m.user_id = (select auth.uid())));
create policy orgs_update on orgs
for update to authenticated
using (private.is_org_admin(id))
with check (private.is_org_admin(id));

-- ORG_MEMBERS: you can read rows of orgs you belong to; admins write.
create policy members_select on org_members
for select to authenticated
using (exists (select 1 from org_members me
  where me.org_id = org_members.org_id
    and me.user_id = (select auth.uid())));
create policy members_write on org_members
for all to authenticated
using (private.is_org_admin(org_id))
with check (private.is_org_admin(org_id));

-- PROJECTS: staff see all org projects; clients see granted ones.
create policy projects_select on projects
for select to authenticated
using (private.can_see_project(id));
create policy projects_write on projects
for all to authenticated
using (private.is_org_staff(org_id))
with check (private.is_org_staff(org_id));

-- PROJECT_ACCESS: only staff manage grants; a client may read own grants.
create policy access_select on project_access
for select to authenticated
using (user_id = (select auth.uid())
  or exists (select 1 from projects p
    where p.id = project_access.project_id
      and private.is_org_staff(p.org_id)));
create policy access_write on project_access
for all to authenticated
using (exists (select 1 from projects p
  where p.id = project_access.project_id
    and private.is_org_staff(p.org_id)))
with check (exists (select 1 from projects p
  where p.id = project_access.project_id
    and private.is_org_staff(p.org_id)));

-- DELIVERABLES: visible if you can see the project.
-- Staff insert/update; nobody updates across the project boundary.
create policy deliverables_select on deliverables
for select to authenticated
using (private.can_see_project(project_id));
create policy deliverables_insert on deliverables
for insert to authenticated
with check (private.is_org_staff(org_id));
create policy deliverables_update on deliverables
for update to authenticated
using (private.can_see_project(project_id))
with check (private.can_see_project(project_id));

-- COMMENTS: anyone who can see the deliverable can read and add;
-- the author must be the current user.
create policy comments_select on comments
for select to authenticated
using (exists (select 1 from deliverables d
  where d.id = comments.deliverable_id
    and private.can_see_project(d.project_id)));
create policy comments_insert on comments
for insert to authenticated
with check (author_id = (select auth.uid())
  and exists (select 1 from deliverables d
    where d.id = comments.deliverable_id
      and private.can_see_project(d.project_id)));

-- APPROVALS: readable by anyone who can see the deliverable;
-- insert only, decided_by must be the current user. No update/delete
-- policy exists, so the audit trail is append-only under RLS.
create policy approvals_select on approvals
for select to authenticated
using (exists (select 1 from deliverables d
  where d.id = approvals.deliverable_id
    and private.can_see_project(d.project_id)));
create policy approvals_insert on approvals
for insert to authenticated
with check (decided_by = (select auth.uid())
  and exists (select 1 from deliverables d
    where d.id = approvals.deliverable_id
      and private.can_see_project(d.project_id)));

The append-only guarantee for approvals is worth pausing on, because it is enforced by absence: there is no for update or for delete policy on the table, and once RLS is enabled a table denies every action that lacks a permitting policy. So even an org owner cannot alter or delete an approval through the API. That absence is the immutable-audit-record requirement from the product spec, enforced structurally rather than by asking nicely.

Testing the wall by attacking it

Policies you wrote and eyeballed are policies you hope work. The gate is a pgTAP suite that assumes a wrong-tenant attacker and asserts the leak fails. Supabase's test helpers (tests.create_supabase_user, tests.authenticate_as) set up users and switch the session's identity so a policy runs exactly as it would for that real user. The critical tests are the negative ones: authenticated as agency B, a read of agency A's data must return zero rows, and a write must be rejected.

supabase/tests/0001_tenancy.test.sqlsql
begin;
select plan(8);

-- Test helpers (installed per the Supabase advanced pgTAP guide).
create extension if not exists "basejump-supabase_test_helpers"
version '0.0.6';

-- Two agencies, two users each.
select tests.create_supabase_user('a_owner');
select tests.create_supabase_user('a_client');
select tests.create_supabase_user('b_owner');

-- Seed as the service role (bypasses RLS) so setup is not the test.
insert into orgs (id, name, slug) values
('00000000-0000-0000-0000-00000000000a', 'Agency A', 'a'),
('00000000-0000-0000-0000-00000000000b', 'Agency B', 'b');
insert into org_members (org_id, user_id, role) values
('00000000-0000-0000-0000-00000000000a',
  tests.get_supabase_uid('a_owner'), 'owner'),
('00000000-0000-0000-0000-00000000000b',
  tests.get_supabase_uid('b_owner'), 'owner');
insert into projects (id, org_id, name) values
('00000000-0000-0000-0000-0000000000a1',
  '00000000-0000-0000-0000-00000000000a', 'A project');
insert into deliverables (id, org_id, project_id, title, created_by)
values ('00000000-0000-0000-0000-0000000000d1',
  '00000000-0000-0000-0000-00000000000a',
  '00000000-0000-0000-0000-0000000000a1', 'A deliverable',
  tests.get_supabase_uid('a_owner'));

-- 1. A's owner sees A's one project.
select tests.authenticate_as('a_owner');
select is(
(select count(*)::int from projects),
1, 'A owner sees exactly A''s projects');

-- 2. THE LEAK TEST: B's owner sees zero of A's projects.
select tests.authenticate_as('b_owner');
select is(
(select count(*)::int from projects),
0, 'B owner sees none of A''s projects (cross-tenant read denied)');

-- 3. B's owner sees zero of A's deliverables.
select is(
(select count(*)::int from deliverables),
0, 'B owner sees none of A''s deliverables');

-- 4. B's owner cannot insert into A's org.
select throws_ok($$
insert into projects (org_id, name) values
  ('00000000-0000-0000-0000-00000000000a', 'B injected')
$$, '42501', null,
'B owner cannot write into A''s org (RLS insert denied)');

-- 5. A un-granted client of A sees zero projects (client visibility).
select tests.create_supabase_user('a_client2');
insert into org_members (org_id, user_id, role) values
('00000000-0000-0000-0000-00000000000a',
  tests.get_supabase_uid('a_client2'), 'client');
select tests.authenticate_as('a_client2');
select is(
(select count(*)::int from projects),
0, 'un-granted client sees no projects even in own org');

-- 6. Grant access, and the same client now sees exactly that project.
select tests.authenticate_as_service_role();
insert into project_access (project_id, user_id) values
('00000000-0000-0000-0000-0000000000a1',
  tests.get_supabase_uid('a_client2'));
select tests.authenticate_as('a_client2');
select is(
(select count(*)::int from projects),
1, 'granted client sees exactly the granted project');

-- 7. Approvals are append-only: no update policy, so update is denied.
select tests.authenticate_as('a_owner');
insert into approvals (org_id, deliverable_id, decided_by, decision)
values ('00000000-0000-0000-0000-00000000000a',
  '00000000-0000-0000-0000-0000000000d1',
  tests.get_supabase_uid('a_owner'), 'approved');
select is(
(select count(*)::int from approvals),
1, 'owner can record an approval');

-- 8. Even the owner cannot mutate the audit record.
select throws_ok($$
update approvals set decision = 'changes_requested'
$$, '42501', null,
'no update policy: approvals are immutable under RLS');

select * from finish();
rollback;

Wire it into the gate: supabase test db runs the suite locally, and the CI job from 4.4 gains a step that spins up the database and runs it. Test 2 is the one that would have caught the leak in the cold open, and it is a merge blocker. When 4.5's dispatch runs, this suite passing (with the negative tests present, not just the positive ones) is the verification, not "the app shows deliverables".

The dispatch

PromptReference dispatch: schema + RLS (lab 4-5)

DISPATCH: briefcase data model, migrations, and tenant isolation

GOAL: the full v1 schema exists as migrations, RLS enforces the TECH-SPEC permissions matrix, and a pgTAP policy suite proves cross-tenant reads and writes fail. supabase db reset is clean and supabase test db is green, including the negative tests.

IN SCOPE: supabase/migrations/0001_create_core_schema.sql, 0002_auth_helpers.sql, 0003_rls_policies.sql; supabase/tests/ 0001_tenancy.test.sql; .github/workflows/ci.yml (add a supabase db + test db step); STATUS.md.

OUT OF SCOPE: any app code, UI, or route handler; auth flows (next dispatch); seed data beyond what the tests create; changing entity names or enums from TECH-SPEC.

DON'T TOUCH: docs/* (naming authority); existing migrations once committed (write new ones to alter); the env hook.

VERIFICATION:

  1. npm run typecheck && npm run lint && npm run build green.
  2. supabase db reset runs clean from empty (migrations valid).
  3. supabase test db green, and I will read the test file to confirm the NEGATIVE tests (2, 3, 4, 5, 8) exist and assert zero rows / denial, not just that positive reads work.
  4. I will read 0003_rls_policies.sql against the TECH-SPEC matrix: every table has RLS enabled and every matrix cell has a policy or is intentionally absent (approvals update/delete).

Read STATUS.md and the TECH-SPEC first. If the matrix and the policies disagree anywhere, stop and report it; do not resolve it by editing the spec.

Where it breaks

The first and worst failure is the test suite that only tests the happy path: authenticate as the owner, confirm they see their data, ship. That suite passes on a schema with RLS entirely disabled. The negative tests are the entire point, which is why the dispatch's verification makes the operator confirm they exist and assert denial. A tenancy suite without a cross-tenant read that returns zero rows is decoration.

The second is forgetting to enable RLS on a new table. A table with RLS off and a policy written on it applies no policy at all; Postgres does not warn you. The countermeasure is a schema-wide test that asserts every table in public has RLS enabled (the Supabase helpers include tests.rls_enabled() for exactly this), added once so that the next table an agent creates fails the gate until its policies exist.

The third is the policy that leaks through a join. A deliverables policy that checks project_id but forgets that a client's project access can be revoked, or that uses the wrong table alias in a subquery, can pass a shallow test and fail a real one. This is why the helper functions are centralized and wrapped: one correct definition of "can see this project", tested directly, beats twelve inline reimplementations tested by accident.

Lablab-4-5Capstone dispatch: schema, migrations, and tested RLS

Goal: Build Briefcase's schema and RLS through the reference dispatch, and make the cross-tenant leak test the gate that a Parts 0-3 reader can watch fail then pass.

Prereqs: the scaffolded repo from 4.4, the Supabase CLI installed and a local stack running (supabase init then supabase start), the TECH-SPEC in docs/.

  1. Save the reference dispatch as dispatches/2026-07-schema.md. Before running it, do the instructive thing: apply only migrations 0001 and 0002 (schema and helpers) WITHOUT 0003, then run just the leak test (test 2). Watch it fail: with RLS not yet enabled, agency B sees agency A's project. That failure is the vulnerability the lesson opened with, reproduced in your own database.
  2. Now run the full dispatch in a fresh session. Review the plan against IN SCOPE.
  3. When it reports done, run VERIFICATION yourself. Do supabase db reset (clean replay) and supabase test db (suite green). Then open the test file and confirm tests 2, 3, 4, 5, and 8 assert zero rows or denial, not just positive reads.
  4. Read 0003_rls_policies.sql with the TECH-SPEC matrix beside it. For each table, confirm RLS is enabled and each cell is covered. Confirm approvals has select and insert policies and deliberately no update or delete.
  5. Add the schema-wide RLS-enabled assertion as a ninth test (use tests.rls_enabled('public')), so the next table cannot ship unguarded. Re-run the suite.
  6. Close the loop: STATUS.md updated, CI green with the new db step, commit, /clear.

Verify

  • With 0003 absent, the leak test failed (B saw A's data); with 0003 applied, it passes (B sees zero rows). You watched both states.
  • supabase db reset replays cleanly from empty and supabase test db is green, including the negative tests and the schema-wide RLS assertion.
  • Every public table has enable row level security in a migration, and approvals has no update or delete policy.
  • CI runs the policy suite and is green; git diff --stat touches only IN SCOPE paths.
>Troubleshooting
  • supabase test db errors that the test helper extension is missing: install it per the advanced pgTAP guide (dbdev then basejump-supabase_test_helpers), or write the negative tests with raw set local role authenticated + set local request.jwt.claim.sub as the testing-overview guide shows. The helpers are convenience, not requirement.
  • A policy causes infinite recursion (checking org_members inside org_members' own policy): that is exactly what the security definer helper in private solves; route the membership check through private.is_org_staff instead of an inline subquery on the same table.
  • The leak test passes even without 0003: you probably have RLS disabled AND are connected as a superuser/service role, which bypasses policies entirely. Run the negative tests as an authenticated non-owner via tests.authenticate_as, never as the service role.

Knowledge check

Knowledge check

Q1A teammate's RLS test suite authenticates as each org's owner and asserts they can read their own projects, comments, and deliverables. All green. Why is this suite dangerous?
Q2The membership-check helpers are declared security definer, in a private schema, with set search_path = ''. What does the search_path setting specifically defend against?
Q3The approvals table has a select policy and an insert policy but no update or delete policy. A dispatch later asks Claude to 'add an endpoint letting owners correct a mistaken approval'. What should happen, and why?
Q4A new dispatch adds a notifications table but the agent forgets to enable RLS on it. The positive tests pass. What is the designed safety net in this lesson's setup?

Sources