ZymuraBlog

Engineering · 19 August 2026 · 6 min read

Testing row-level security for what should fail

Restrictive mistakes break the demo. Permissive mistakes break nothing at all, which is the problem.

A too-permissive policy does not throw. It returns rows.

Row-level security in Postgres is the right answer for multi-tenant data. It puts the access rule next to the data instead of in application code, so a forgotten WHERE user_id = ... cannot leak another tenant's rows.

It also has a failure mode that is easy to miss, and it is the same shape as most of the bugs I find genuinely dangerous: getting it wrong does not produce an error.

The asymmetry

Consider two mistakes.

Too restrictive. Your policy is stricter than intended. The app breaks immediately, loudly, in development. Someone's own data does not appear. You fix it in minutes.

Too permissive. Your policy allows more than intended. Nothing breaks. Every test passes, because every test asks "can this user see their own data?" and the answer is still yes. The bug only manifests as a second tenant's rows appearing in a response nobody is inspecting.

Restrictive failures are self-reporting. Permissive failures are silent. Almost all testing effort naturally goes toward the former, because that is what breaks the demo.

Test what should fail

The fix is not clever, just unusual: write assertions about what a user cannot reach. For every table with a policy, you want a test that authenticates as user A, requests user B's row by primary key, and asserts the result is empty.

-- as user A, asking for a row that belongs to user B
select count(*) from flows where id = '<user_b_flow_id>';
-- expected: 0, not an error

Note the expectation. RLS does not raise a permission error on a filtered read — it returns zero rows, exactly as if the row did not exist. If your test asserts on an exception you will never catch the regression.

Four things worth checking specifically

The general principle

This is the same class of problem as a build that runs nothing (the build that succeeded at nothing) or a flow that completes without doing anything (the flow that ran and did nothing). In each case the system reports success and the success is real — it just is not the thing you cared about.

Where a mistake is silent, the test has to assert on the absence, because nothing else will.

Flows that run without youScheduled workflow automation on our servers, not in a browser tab. 51 connectors, visual builder, free tier included.

Open the app