The answer. Row-level security composes, and it composes as the calling user. When a policy on tenants says "there exists a memberships row for me", Postgres evaluates that sub-select with the caller's role, which means memberships policies apply too. With no policy on the inner table the sub-select sees nothing, the outer policy is false for every row, and the API returns an empty array with no error. That is the front door and the inner door of a castle: both must open. The failure mode is quiet, and so is its opposite: an over-broad using (true) on a leaf table cascades read access into everything that depends on it. Two habits keep the chain sane: name the target role on every policy, and put shared predicates into functions in a schema that is not exposed to the API, so the logic exists once and can be changed once.
The pattern.
create schema if not exists rls_helpers; -- not in "Exposed schemas": no RPC surface
create or replace function rls_helpers.has_tenant_access(t text) returns boolean
language sql stable set search_path = '' as $$
select coalesce(((select auth.jwt())->'app_metadata'->'tenants') ? t, false)
$$;
create or replace function rls_helpers.is_same_profile(p bigint) returns boolean
language sql stable security definer set search_path = '' as $$ -- definer: skips profiles' own RLS
select exists (select 1 from public.profiles where id = p and user_id = (select auth.uid()))
$$;
create policy "members read comments" on public.comments for select to authenticated using (rls_helpers.has_tenant_access(comments.tenant));
create policy "author edits comment" on public.comments for update to authenticated
using (rls_helpers.is_same_profile(comments.created_by)) with check (rls_helpers.is_same_profile(comments.created_by));
Watch out.
- Permissive policies on one table combine with OR, restrictive ones with AND; the distinction only matters once a table has two.
- A
security definerhelper is a deliberate hole: it must take the caller's identity fromauth.uid()and never from an argument. - There is no "show dependent policies" view. Write the chain down in the migration comment, and test one positive and one negative case per table.
Related: rls-test-set-local-role-and-jwt-claims · security-definer-rpc-is-a-controlled-hole-in-rls · multi-tenancy-tenant-id-on-every-row-and-in-the-jwt