postgres-view-privacy-mutation-proof
Use when adding or reviewing a Postgres/Supabase view (leaderboard, feed, board) over an RLS table, when a view leaks rows or a board reads empty for everyone, or when security_invoker=true is the onl
Files1
View on GitHubSKILL.mdskills/managed/postgres-view-privacy-mutation-proof/SKILL.md
GitHubProving a view really hides rows
A view over an RLS-protected table is a privacy boundary. By default a view runs as
its owner, so it bypasses RLS and leaks every row unless it is created
with (security_invoker = true). Schema-level checks are cheap and worth having,
but they do not prove the boundary holds. Read rows through the view, as real
roles.
The schema-level check: necessary, not sufficient
Do NOT verify via pg_class.relrowsecurity — that column is always f for views
and proves nothing. Check reloptions:
select relname, reloptions from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v';
-- expect {security_invoker=true}
The two failure shapes a setting check misses
Definer-wrapped board — leaks everything, setting still reads
true.create function all_board() returns setof profiles language sql stable security definer as $$ select * from profiles $$; create view leaderboard with (security_invoker = true) as select ... from all_board() p; -- setting true, RLS bypassed insideRealistic because it arrives as a "fix" when a board looks empty.
Narrowed predicate — board reads empty for everyone. Tighten the visibility function (
can_view(owner) -> owner = auth.uid()) and every refusal assertion still passes: nothing leaked, nothing worked. A suite that only asserts zeros passes a board that shows nobody anything.
The assertion set
For each board, with real set local role authenticated + the probe uid:
| reader | expectation |
|---|---|
| stranger (no friendship) | 0 rows for the private hunter |
| accepted friend | 1 row — otherwise the zero above passes for the wrong reason |
| the reader themselves | 1 row |
The friend/self rows are the load-bearing half. Without them, mutation 2 is invisible.
Procedure
- Throwaway database, migrations applied in order:
docker run -d --rm --name pg -e POSTGRES_PASSWORD=probe postgres:16 for i in $(seq 1 30); do docker exec pg pg_isready -U postgres && break; sleep 2; done for f in test/stub.sql migrations/*.sql; do docker exec -i pg psql -U postgres -q -v ON_ERROR_STOP=1 < "$f" done docker cp test/assert_all.sql pg:/tmp/a.sql docker exec pg psql -U postgres -v ON_ERROR_STOP=1 -f /tmp/a.sql - Add the assertions; confirm green.
- Mutate and attribute. Apply each mutation with
psql -qc, re-run, and record which assertion caught it — existing or new:- drop
security_invokerfrom the view - broaden the visibility predicate to
select true - wrap the board in a
SECURITY DEFINERfunction - narrow the predicate to self-only Expect some to be caught by checks that already existed. Say so; the new coverage is only what the last two prove.
- drop
- Restore each mutation immediately (
create or replaceback), and re-run the whole suite three times. The checks mutate the rows they depend on, so fixtures needon conflict do updateor the suite poisons itself. - Tear the container down.
Traps
- Attribute the catch, don't assume it. Grep the failure message; a pre-existing table-level assertion often fires first and hides whether the new one works at all.
- Don't claim counts you didn't measure.
grep -c assert_trueincludes the helper's own definition; countperform assert_true(call sites.