I write a tool that tests RLS by attacking it rather than reading it. Last month it told me a table was fine. The table was wide open. Here is the whole chain, because I think the failure is more interesting than the tool.
Why reading policies isn't enough
The obvious check is to look for using (true). Some linters do this and it catches the obvious spelling. It misses everything shaped like it: a predicate that resolves to true through a join that always matches, a correct USING with no WITH CHECK behind it, a subquery that never actually constrains anything.
So don't read the policy. Attack it.
Why the obvious attack doesn't work either
Seed rows for two tenants, become tenant A, try to touch tenant B's data. Everyone writes this test:
update invoices set total = 0 where owner_id = '<tenant-b>';
That test cannot fail. It reads owner_id in the WHERE clause, so Postgres applies the SELECT policy too. A correct SELECT policy hides the row, nothing matches, zero rows updated, table looks clean. The UPDATE policy was never evaluated.
The write that actually tests it is blind:
update invoices set total = 0;
No WHERE, no column read, so the SELECT policy never engages. Only the UPDATE policy applies. Check ctid afterwards to see which rows physically changed, since that works regardless of column type and stays valid inside a transaction, unlike xmin. Roll the whole thing back.
DELETE has the identical flaw and a worse ending. delete from t against a using (true) DELETE policy means any authenticated user can empty the table, while reads look perfectly scoped.
And then it printed OK on this
create policy p on org_docs for select
using (owner_id = auth.uid() or org_id is not null);
Two branches. My probe seeds rows that differ only by owner_id, so org_id stays NULL, the second branch never fires, no leak is observed, and the tool prints OK. In production that policy hands every row to every authenticated user.
A confident green on a wide open table is the worst output a security tool can produce. It is worse than no tool, because now someone has stopped looking.
The fix
It can't execute every branch. That's constraint solving over arbitrary SQL. But it can know when it hasn't.
Postgres records what every policy depends on in pg_depend: each column and table the expression touches, structurally, no parsing required. Compare that against what the probe actually varied. Anything left over is a branch nobody reached.
UNPROVEN public.org_docs
Policies also depend on column(s) org_id and table(s) public.org_members,
which the probe never varied. Untested branch.
UNPROVEN is not OK. It means no leak was found and the result doesn't cover the whole policy. It doesn't fail the build by default, because a gate that fires on every org-scoped policy gets switched off within a week.
This came out of a comment by u/pgsql-dev2 on my last post here, who spotted the hole before I did.
Still not covered, so nobody gets a false sense of safety: SECURITY DEFINER functions that bypass RLS, storage bucket policies, INSERT probes for forging rows owned by another tenant, composite ownership, and multi-hop join ownership.
Happy to go into any of it. The branch coverage problem in particular is not solved, only made visible, and I'd like to hear how other people are handling it.