Per-row sharing in RLS without making it transitive
A share is a second route to a row, running alongside tenant membership. The danger is that the second route turns out to be wider than the first, in ways that look reasonable while you're writing them.
Sooner or later one specific row needs to reach one specific person who isn't in the tenant — a document shared with a client, a report sent to a contractor. Three ways that goes wrong.
1. Sharing becomes transitive
-- "you may share what you can see" create policy "share a document" on document_shares for insert with check (can_access_document(document_id, 'view'));
Read it back: anyone who can see the row may grant it to anyone else. Every recipient becomes a distributor, the grant graph escapes the tenant, and revoking the original share does nothing about the copies made downstream.
Granting is a tenant right, not something a grant confers.
The check should ask whether you are a member of the document's organization, deliberately not whether you can see the document:
create policy "only members grant access" on document_shares for insert with check ( granted_by = auth.uid() and user_id <> auth.uid() and exists ( select 1 from documents d where d.id = document_shares.document_id and has_org_role(d.organization_id, 'member') ) );
2. One expression for every verb
The tempting simplification is to write the access check once and reuse it in all four
policies. Do that and a share meant for reading silently permits writing, because
SELECT and UPDATE were asked the same question.
The levels have to differ per verb:
create type share_level as enum ('view', 'edit'); -- ordered create policy "read if a member or shared with" on documents for select using (can_access_document(id, 'view')); create policy "write if a member or shared to edit" on documents for update using (can_access_document(id, 'edit')) with check (can_access_document(id, 'edit'));
Deletion isn't on that list at all. A share grants access to a row; it is never a licence to destroy it. Creation and deletion stay with the tenant.
3. The share row is itself data
document_shares needs its own policies, and it's easy to leave it open
"because it's just a join table". It records who has access to what — a roster of your
customers' collaborators. Recipients may see their own grant, members may see grants on
their organization's documents, nobody else sees anything.
Its SELECT policy also has to consult document_shares, which
recurses — the same trap as the membership lookup in
tenant isolation, defused the same way,
with a security definer helper.
The shape that holds
One function answers "how much access does this caller have to this row, by any route", and every policy calls it with the level that verb requires:
create or replace function public.can_access_document(doc_id uuid, minimum share_level) returns boolean language sql security definer stable set search_path = public as $$ select coalesce((select has_org_role(d.organization_id, 'member') from documents d where d.id = doc_id), false) or coalesce(public.share_level(doc_id) >= minimum, false) $$;
Both routes live in one place, so they can't drift apart across four policies — which is what happens when the membership half gets updated and the share half doesn't.
What to test
| Assertion | Guards |
|---|---|
| The recipient sees the shared row | The happy path |
| The recipient gains no other row, and no membership | Scope of a share |
| A view-level share cannot edit | Per-verb levels |
| An edit-level share can edit | Not over-tightened |
| Even edit cannot delete | Destruction stays a tenant right |
| A recipient cannot raise their own level | Escalation |
| A recipient cannot re-share | Transitivity |
A sharer cannot forge granted_by | Attribution |
| Revoking removes access immediately | No caching in policy logic |
| Deleting a document drops its shares | No orphaned grants |
Then break it on purpose. Change the insert policy to
can_access_document(document_id, 'view') and re-run: the re-share
assertion fails, and two more fail behind it as the share graph spreads. Three
failures from one plausible-looking simplification is the point.
Assertions like these only mean something if the database actually enforces the
policies while they run, as a real user rather than as postgres, which
bypasses row-level security entirely.
rls-test-harness is a
free, MIT-licensed setup that does that: point it at your own schema and get
results in about three seconds, with no Docker and no cloud project.
Adapting it
- Sharing by link rather than by user: replace
user_idwith a token column and treat it exactly like the token in invite acceptance — long, secret, expiring, revocable. - Sharing to a whole organization: add a nullable
shared_with_org_idand extend the helper. Keep it as one more branch in the same function, never as a new expression in each policy. - Expiring shares: add
expires_atand put it in the helper, not the policies — one place to forget instead of four. - Audit: keep
granted_byandcreated_at. Shares are the first thing customers ask about after an incident.
Keystone — multi-tenant, correct from the first commit
A Next.js + Supabase B2B starter kit with organizations, roles, invites, and per-org row-level security already wired up and audited. One-time payment, full source, unlimited projects.
See what's inside — $49 More guides