Database
Postgres RLS in Symfony: three green isolation tests, and the UPDATE that moves tenant 1's invoices into tenant 2's books
Eric Mollenthiel Dev.to (EN Zone)
4 views
Two articles ago I wrote that the Postgres role which runs your migrations bypasses every row-level security policy. Last week I wrote that with one tenant in the fixture, your isolation suite passes with no policy at all. Both came out of the same comment thread, and by the end of it we had three assertions that, together, cover every broken database I had been able to build.
Then Marco pointed out what he liked about the pair: it never leaves the enforcement boundary it is checking. So I went looking for the next place where a green suite means nothing, and it took one line of SQL to find it. All three assertions only read. None of them says a word about what a tenant can write.
Here is a database where tenant 1 can move every one of its invoices into tenant 2's books, in one statement, and every isolation test in this series is green on it.
The loud error that gets you there
You do not arrive at that database on purpose. You arrive at it by fixing an error.
Once a table has FORCE ROW LEVEL SECURITY, the owner is subject to the policy too. That is the correct setting, it is the whole point of the first article, and it has a consequence the first time you seed a fixture: the seeding connection is the owner, the policy reads current_setting('app.tenant_id'), nothing has set it, and Postgres refuses to insert.
no context, INSERT (1,1,100) : ERROR: new row violates row-level security policy for table "invoice"
context=1, INSERT (1,1,100) : ok
context=1, INSERT (2,2,200) : ERROR: new row violates row-level security policy for table "invoice"
Even with the context set, the second tenant's row is refused, because you are inserting tenant 2's row while claiming to be tenant 1. My own bench hit this while I was writing the article: the seed step ran as the owner, under FORCE, with no context, and the first two scenarios ran on an empty table until I noticed.
The error is loud, it is correct, and it is annoying in exactly the place where people are in a hurry. The obvious repair is to relax the check:
CREATE POLICY tenant_isolation ON invoice
USING (tenant_id = current_setting('app.tenant_id', true)::int)
WITH CHECK (true);
Reads are still filtered, the seed goes through, the suite is green. It is the same shape as the NULLIF trap from the previous article: a tempting fix that turns a loud failure into a silent one. And unlike NULLIF, this one is not a degraded mode. It is a hole.
Three policies, three assertions, four writes
Same table, same two rows, same FORCE, three shapes of policy. The three columns on the left are the assertions the thread built. Equality is the double run: the owner and the serving role return the same set. Scoped is the ordinary one: tenant 1 sees only its rows. Disjoint is Marco's: tenant 2 sees something, and none of it is tenant 1's. Then tenant 1, under its own context, attempts four writes aimed at tenant 2.
equality scoped disjoint | INSERT INSERT..RETURNING UPDATE..WHERE UPDATE no WHERE
USING only GREEN GREEN GREEN | refused refused refused refused
USING + WITH CHECK (same) GREEN GREEN GREEN | refused refused refused refused
USING + WITH CHECK (true) GREEN GREEN GREEN | ACCEPTED refused refused ACCEPTED
The first two rows are the sound ones. When WITH CHECK is omitted, Postgres uses the USING expression for it, so "USING only" is not a shortcut, it is the correct policy written short. Every cross-tenant write is refused on both.
The bottom row is the one the seeding error pushes you towards. The same three assertions that caught every broken database in the previous article are green on it, and tenant 1 can file an invoice into tenant 2's books.
The two refusals on that row are not what they look like
WITH CHECK (true) accepts everything, so what is refusing UPDATE ... WHERE id = 1 and INSERT ... RETURNING id on that row?
Not the write policy. It is the USING expression of the read side, applied to the new row, and it only applies because the statement reads a column. Footnote [a] of the "Policies Applied by Command Type" table in CREATE POLICY says the SELECT policy is checked against the new row when read access is required to it, "for example, a WHERE or RETURNING clause that refers to columns from the relation". The plain INSERT does not read, so it passes. The same INSERT with RETURNING id reads, so it is refused. UPDATE ... WHERE id = 1 reads id, refused. UPDATE ... SET tenant_id = 2 with no WHERE reads nothing, accepted.
So on that database the write side is guarded by the read policy, by accident, and only while the statement happens to read something. Take the read away and there is nothing left:
before : tenant 1 = [1], tenant 2 = [2]
tenant 1 runs, under its own context: UPDATE invoice SET tenant_id = 2
after : tenant 1 = [], tenant 2 = [1,2]
USING still filters on the way in, so tenant 1 only touches its own rows. Then it hands all of them to tenant 2. The tenant that lost its data cannot see where it went, and the tenant that received it never asked for it. No error, no log line, and three green assertions.
Two smaller results from the same bench, for completeness. DELETE FROM invoice with no WHERE under tenant 1 deletes tenant 1's rows only: USING filters the existing rows, and a DELETE has no new row to check. UPDATE invoice SET amount = 0 with no WHERE is likewise confined to tenant 1. The hole is specifically a write that changes which tenant a row belongs to, or inserts a row under another tenant's label, on a policy whose WITH CHECK no longer looks at tenant_id.
The fourth assertion is one line, and it belongs in the same test
Equality and disjointness say the boundary separates what is already in the table. Neither says anything about what a tenant can put there. The missing assertion is: as tenant 1, attempt a write labelled tenant 2, and assert that it raises.
function assertCrossTenantWriteRaises(Connection $c, int $asTenant, int $towardsTenant): void
{
$c->beginTransaction();
try {
$c->executeStatement('SELECT set_config(?, ?, true)', ['app.tenant_id', (string) $asTenant]);
$c->executeStatement(
'INSERT INTO invoice (id, tenant_id, amount) VALUES (?, ?, ?)',
[999, $towardsTenant, 1]
);
self::fail('a write labelled with another tenant went through');
} catch (DriverException $e) {
self::assertSame('42501', $e->getSQLState());
} finally {
$c->rollBack();
}
}
The SQLSTATE matters. 42501 is insufficient_privilege, which is what a policy violation raises. Catching any exception would let a typo in the statement pass for a refusal.
Run against the three policies:
USING only GREEN (42501 raised)
USING + WITH CHECK GREEN (42501 raised)
USING + WITH CHECK(true) RED (insert accepted)
Red in exactly the row the other three sleep through. With the four assertions together, I no longer have a broken database that the suite stays green on. I had that sentence about three assertions a week ago, so take it for what it is: the next comment thread may well find the fifth.
Seed with the context, not around it
The honest fix for the seeding error is not to weaken the policy, it is to seed the way the application writes: set the tenant context, insert that tenant's rows, set the next one. It is the same set_config(?, ?, true) call the tests already use, inside the same transaction. A fixture that goes through the policy is also a fixture that proves the policy accepts legitimate writes, which is the positive half of the claim and worth having on its own.
If you seed with a superuser or a BYPASSRLS role instead, it works, and it puts the seed outside the boundary again. That is fine for a fixture as long as the assertions themselves stay inside it, which is what the previous article was about.
Reproduce it
Two bash scripts and three PHP files. They create a throwaway database and two roles, build the table under each policy, run the three read assertions and the four writes under the serving connection, show the damage from the unguarded UPDATE, run the fourth assertion, and drop everything on the way out. PostgreSQL 18.3, PHP 8.5.7, Doctrine DBAL. Nothing in them is specific to my schema, and I would rather you ran them against yours than believed my output. I wrote them alongside ShipAnvil, a multi-tenant Symfony kit that scopes tenants with a Doctrine filter and ships no RLS of its own. There is no product in them either: a table called invoice, two rows, and a policy with a hole in it.
Thanks to Marco for the sentence that started this: the pair stays inside the boundary. It does. It also only reads. If you are running RLS in a Symfony app, the cheapest thing you can do this week is open your policies and look at what WITH CHECK says. If one of them says true, you already know what your isolation suite cannot see.
Read original: https://dev.to/mollenthiel/postgres-rls-in-symfony-three-green-isolation-tests-and-the-update-that-moves-tenant-1s-invoices-4bjh
← Previous
Verifying every number in a draft against its source does not make the draft honest.
Next →
Custom AI Chatbot Development Explained: Timelines, Risks, and How to Choose a Vendor
Related
Turning Mermaid ER diagrams into shareable 3D schema tours
Database
7
DEV Community
finally understand why redis blocks on one slow command, wish I knew this earlier
Database
6
Reddit r/webdev
Tracing Which Internal Consumer Read Each Webhook Event (One Registration, One Queue)
Database
5
DEV Community
NO idea where to start learning SQL?
Database
8
DEV Community
Comments0
No comments yet — be the first