RowShield
Guides

Two permissive policies are OR’d together — and that surprises people

The mental model most people bring to policies is "more rules, more restriction". Postgres inverts it for the default flavour: permissive policies accumulate with OR, so each additional policy can only widen what is accessible. Tightening means removing or restructuring, never adding.

This page walks the semantics and the three traps they produce. RowShield does not automate general combination analysis — the constant-true case is covered by the tautology detector — so consider this the review checklist for any table with more than one permissive policy.

RowShield does not detect this yet. This guide gives you the catalog queries to check it yourself. The nine rules that do ship are listed on the rules index.

The combination rule

For a command and role, all applicable PERMISSIVE policies merge as alternatives: the row is accessible if any single policy admits it. To intersect, you would need RESTRICTIVE policies, or fold conditions into fewer statements.

The corollary surprises people in code review: a proposal titled "add a stricter policy" usually changes nothing, because the loose one still stands beside it.

A useful framing is one alternative per row of the catalog: every applicable permissive policy contributes a single OR arm to the predicate Postgres builds. Access can only grow as arms are added, never shrink. That makes review mechanical — enumerate the arms per (table, command, role), ask what each contributes, and any arm granting a strict superset of the intended set is the defect, whatever its name promises.

Trap one: the demo policy that never left

A scaffolded allow-all sits at creation; the real policy arrives later and coexists. Access equals the union — the scaffold wins. Audit with:

SELECT tablename, policyname, cmd, roles, qual
FROM pg_catalog.pg_policies
WHERE schemaname = 'public' AND permissive = 'PERMISSIVE'
ORDER BY tablename, cmd, policyname;

Trap two: role-splitting that leaks between splits

Separate policies for anon and authenticated feel compartmentalised, but each is checked only against its own role — and any condition like OR auth.role() IS NOT NULL smuggles one role's grant into the other. Conditions belong to data, not session vibes.

The audit smell is a condition referencing the wrong side: anon policies filtering on authenticated-only concepts, or the reverse. Each policy should be readable without knowing the other exists.

Trap three: additive fixes to incidents

After a scare, engineers append a narrow policy addressing the reported path while leaving the broad one untouched. The report closes; the hole remains, now camouflaged. Remediation for widening is subtraction: drop or rewrite the permissive source, then verify by probing as the anon caller — which is exactly what RowShield's free audit does, and what continuous scanning repeats on schedule so the union never quietly regrows.

A useful convention: remediation after incidents edits existing policies in place and records the change in migration history, so additive habits have nowhere to hide.

Reading a multi-policy table safely

Group the audit query output by table, command and role set, then read each group as one sentence: this command, for these roles, is admitted if any of these conditions hold. Scaffolding names — allow_all, dev_open, temp_fix — jump out at that altitude, and so do identical conditions repeated across roles, which usually mark a copy-paste that drifted. The discipline matters most after incidents, when policies accrete faster than features do.

Watch also for silent interactions between groups: a policy FOR SELECT TO authenticated and another FOR ALL TO anon do not stay in separate lanes if either references session-shape functions such as auth.role(), because one group's condition can then be satisfied from the other's session. Data predicates — ownership columns, membership joins — keep lanes honest.

Verifying the tightened state

After subtractive remediation, verify from both directions: the previously over-exposed caller should now be denied or narrowed, and the legitimate caller should retain exactly its intended rows. Re-running the audit query should show one policy per (command, role-group) with a precise predicate. Continuous probing as the anon principal is what catches the union regrowing through the next sprint of well-meaning additions.

Keep the probe credentials fixed across runs so results compare cleanly; rotating keys mid-investigation manufactures diffs that are about credentials, not policies.

Frequently asked

How do I tighten access without deleting history?
Rewrite the broad policy in place rather than layering beside it: one policy per (table, command, role-group) with a precise predicate, plus RESTRICTIVE clauses for genuinely global filters such as soft-delete masks. History lives in your migrations, not in accumulated live policies.
Does RowShield flag overlapping policies?
It flags constant-true permissive policies outright and reports every policy on drifted tables during diff scans. General overlap analysis — proving one predicate subsumes another — is not automated; the audit query above fills that gap today.
Is OR-combination ever useful?
Yes: role-based alternatives such as owner-or-admin express naturally as two permissive policies, and the disjunction is exactly what you mean. The failure mode is unintentional union — scaffolding left standing, or a broad grant surviving an additive incident fix.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
supabase permissive policy ormultiple rls policies combinedpostgres policy union wideningtighten rls policy supabase