SaaS-architectuur
Row-level security in PostgreSQL: waar tenants lekken
RLS aanzetten is vijf regels SQL. Dit zijn de vijf manieren waarop hij daarna stilletjes uitstaat of eromheen lekt, elk nagemeten in PostgreSQL 17.
In welk isolatiemodel kies je komen we uit op gedeelde tabellen met row-level security: elke rij draagt een tenant_id, en de database weigert zelf de rijen die niet bij de huidige klant horen. Dat is de aanbeveling. Dit artikel gaat over wat er daarna gebeurt.
Want RLS aanzetten is vijf regels SQL, en precies dat maakt hem gevaarlijk. Een beleid dat er staat en niets doet ziet er in een migratie identiek uit aan een beleid dat werkt. Er komt geen waarschuwing, geen trage query, geen foutmelding — alleen rijen die zichtbaar zijn terwijl je denkt van niet.
We hebben de vijf gaten die we in de praktijk tegenkomen nagelopen in een lege PostgreSQL 17.11, met twee klanten en twee facturen. Alles hieronder is uitgevoerd, niet geredeneerd.
Waarom werkt row-level security soms niet?
Row-level security werkt niet als de verbinding het beleid mag omzeilen, en dat is vaker het geval dan het lijkt. De eigenaar van een tabel omzeilt zijn eigen beleid tenzij je FORCE ROW LEVEL SECURITY aanzet, en een migratie die met dezelfde databasegebruiker draait als de applicatie maakt die gebruiker de eigenaar. Superusers en rollen met BYPASSRLS omzeilen het altijd, ook mét FORCE. Daarnaast lekt RLS langs drie routes die niet over rollen gaan: een sessievariabele die per verbinding blijft hangen, een vreemde sleutel die om het beleid heen kijkt, en een tabel die later is toegevoegd en geen beleid kreeg.
De opzet, en wat de database ervan maakt
Dit is het volledige recept, op één tabel:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
USING bepaalt welke rijen zichtbaar zijn, WITH CHECK welke rijen weggeschreven mogen worden. Geef je alleen USING, dan neemt PostgreSQL diezelfde voorwaarde als WITH CHECK — wat meestal is wat je wilt, maar niet altijd, en het is beter om het op te schrijven dan het te weten.
De applicatie zet per verbinding één variabele: set_config('app.tenant_id', '…', false). Wat er dan gebeurt, is geen filter achteraf. De planner zet de beleidsvoorwaarde in de query zelf, vóór jouw eigen voorwaarden. In onze tabel van 200.000 facturen, waarvan 2.003 van één klant, gaf SELECT count(*) FROM invoices dit plan:
Aggregate
-> Bitmap Heap Scan on invoices (actual rows=2003)
Recheck Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id', true), ''))::uuid)
-> Bitmap Index Scan on invoices_tenant_idx (actual rows=2003)
Index Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id', true), ''))::uuid)
De beleidsvoorwaarde is een Index Cond geworden. Dat is het belangrijkste dat je over de kosten van RLS moet weten: met een index op tenant_id is het beleid geen extra scan maar de scan zelf. Zonder die index is het wél een volledige tabelscan, want de beleidsvoorwaarde staat vooraan en je eigen WHERE komt er pas achteraan. Voegden we WHERE bedrag > 900 toe, dan bleef het beleid de Index Cond en werd onze eigen voorwaarde een Filter daarna. De documentatie beschrijft precies dat: de beleidsexpressie wordt per rij geëvalueerd “prior to any conditions or functions coming from the user’s query”, met leakproof-functies als enige uitzondering.
Gat 1: de eigenaar van de tabel
Dit is het gat dat het vaakst open staat, en het enige waarvan de gevolgen volledig zijn: geen scheiding, nergens.
We zetten RLS aan op invoices en voerden de query uit als de rol die de tabel had aangemaakt:
| situatie | rijen zichtbaar van de 2 |
|---|---|
ENABLE ROW LEVEL SECURITY, geen beleid | 2 |
ENABLE, beleid, geen FORCE | 2 |
ENABLE, beleid, FORCE, geen klantcontext | 0 |
ENABLE, beleid, FORCE, klantcontext gezet | 1 |
Let op de eerste regel. De documentatie zegt dat een tabel met RLS aan en zonder beleid een default-deny krijgt: “no rows are visible or can be modified”. Voor de eigenaar geldt dat niet — die ziet gewoon alles. De veiligheidsklep waar je op rekent bestaat voor precies die rol niet.
En de eigenaar is bijna altijd je applicatie. Draaien je migraties met dezelfde databasegebruiker als je applicatiecode — de standaard in zo’n beetje elk framework — dan is die gebruiker de eigenaar van elke tabel die hij heeft aangemaakt, en dan doet je beleid niets. Bij booxx is dat om twee redenen niet zo: migraties draaien op een aparte verbinding met DDL-rechten, de applicatie verbindt als een rol die is aangemaakt met NOSUPERUSER NOBYPASSRLS, en op alle 48 tabellen met een beleid staat zowel ENABLE als FORCE.
Controleer dit als eerste. Eén regel per tabel, en zonder die regel is de rest van dit artikel niet eens relevant.
Gat 2: de lege context die niet leeg is
De gebruikelijke schrijfwijze van de beleidsvoorwaarde is current_setting('app.tenant_id', true). Dat tweede argument is missing_ok: zonder dat argument gooit de functie een fout als de variabele niet bestaat, mét dat argument geeft hij NULL. En tenant_id = NULL is niet waar, dus een verbinding zonder klantcontext ziet niets. Zo hoort het: hinderlijk, maar de goede kant op fout.
Alleen geldt dat NULL maar in één van de drie toestanden. Nagemeten:
| toestand van de sessie | current_setting('app.x', true) |
|---|---|
| nooit gezet in deze sessie | NULL |
gezet met SET LOCAL, transactie is afgelopen | '' (lege tekst) |
gezet en daarna RESET | '' (lege tekst) |
Zodra de variabele één keer heeft bestaan, is “leeg” geen NULL meer maar een lege tekst. En ''::uuid is geen NULL maar een fout: invalid input syntax for type uuid: "". Een beleid met een rechtstreekse cast werkt dus prima tot de eerste transactie die zijn context netjes opruimt, en gooit daarna een databasefout op elke query naar die tabel. Geen lek, maar wel een storing — en een die je op een testomgeving met één verbinding zelden tegenkomt.
Vandaar de nullif(…, '') in het recept hierboven. Daarmee gaven alle drie de toestanden netjes nul rijen. Bewaar je tenant_id als tekst, dan kun je hetzelfde bereiken door expliciet op <> '' te toetsen vóór je vergelijkt; dat is wat booxx doet.
Wat je in geen enkele variant moet schrijven is de omgekeerde vorm — “als er geen context is, laat dan alles zien”. Dat leest als een vriendelijkheid voor achtergrondtaken en seeders, en het is de deur zelf.
Gat 3: de verbinding die van klant wisselt
De sessievariabele hangt aan een verbinding, niet aan een verzoek. Dat is prima zolang één verzoek één verbinding heeft. Zet er een pooler tussen die per transactie een verbinding uitdeelt — PgBouncer in transaction mode, en de meeste beheerde “pooler”-eindpunten — dan wisselt de backend onder je voeten. Twee uitkomsten, en de tweede is de ernstige: de variabele is weg en je queries geven nul rijen, of de verbinding draagt nog de klant van het vorige verzoek en je queries geven de rijen van iemand anders.
Dit is ook precies de reden dat SET LOCAL en een sessievariabele niet door elkaar te gebruiken zijn. set_config(…, true) geldt alleen in de lopende transactie; set_config(…, false) voor de rest van de sessie. Poolen kan alleen als je de eerste vorm gebruikt en je hele eenheid van werk in één expliciete transactie zit. Dat is een verbouwing van je datalaag, geen instelling.
Wie de tweede vorm gebruikt — verreweg het gewoonste — moet dus kunnen aantonen dat zijn verbinding sessie-gebonden is, en dat is een dingetje: een pooler ziet er in een verbindingsreeks uit als een gewone databaseserver. Bij booxx staat daar een probe voor die bij elke webrequest en elke achtergrondtaak één keer draait: zet een wegwerpvariabele in de ene ronde en lees hem terug in een tweede, losse ronde. Komt hij niet terug, dan weigert de applicatie te starten. Dat is een paar regels code voor de klasse fouten waar geen enkele test je voor waarschuwt.
Gat 4: de vreemde sleutel die om het beleid heen kijkt
Dit gat staat in de documentatie en wordt zelden geciteerd: “Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security”. Er staat zelfs bij waar dat toe leidt — “covert channel” leaks.
Zo ziet dat er in de praktijk uit. Klant 1 ziet één factuur, id 1; factuur id 2 is van klant 2 en onzichtbaar. Dan, als klant 1:
| poging | uitkomst |
|---|---|
een factuurregel wegschrijven met invoice_id = 2 (onzichtbaar, bestaat) | gelukt |
een factuurregel wegschrijven met invoice_id = 999 (bestaat niet) | fout: violates foreign key constraint |
| een factuur wegschrijven met een nummer dat klant 2 al heeft | fout: duplicate key value |
| een factuur wegschrijven met een vrij nummer | gelukt |
Twee dingen tegelijk. De eerste regel is geen lek maar een schrijffout in de gegevens: er hangt nu een regel van klant 1 aan een factuur van klant 2, en geen van beide zal hem ooit zien staan. De tweede en derde regel zijn wél een lek: het verschil tussen “gelukt” en “fout” vertelt je of een rij bestaat die je niet mag zien. Met een oplopend nummer of een factuurnummerreeks is dat genoeg om af te tellen hoeveel er zijn.
De reparatie is dat de sleutels zelf de klant meenemen:
-- uniek binnen de klant, niet over klanten heen
CREATE UNIQUE INDEX invoices_nummer_per_tenant ON invoices (tenant_id, nummer);
-- en de verwijzing pakt de klant erbij
ALTER TABLE invoices ADD CONSTRAINT invoices_tenant_id_uniek UNIQUE (tenant_id, id);
ALTER TABLE invoice_lines ADD FOREIGN KEY (tenant_id, invoice_id)
REFERENCES invoices (tenant_id, id);
Nagemeten na die wijziging: de regel aan de factuur van klant 2 hangen geeft nu een sleutelfout, hetzelfde factuurnummer als klant 2 gebruiken lukt gewoon, en de eigen factuur met de eigen regel werkt. Het verschil tussen “gelukt” en “fout” zegt niets meer over een andere klant.
Gat 5: de tabel die er later bij kwam
Gaten 1 tot en met 4 zitten in het ontwerp. Dit gat zit in de tijd. Een tabel die vandaag wordt toegevoegd en morgen in productie staat, heeft geen beleid tenzij iemand eraan dacht — en een tabel zonder ENABLE ROW LEVEL SECURITY heeft geen enkele bescherming, ook geen default-deny.
Dat is geen kwestie van discipline maar van meetbaarheid. Deze query geeft elke tabel met een tenant_id waar iets aan ontbreekt:
SELECT c.relname AS tabel,
c.relrowsecurity AS rls_aan,
c.relforcerowsecurity AS geforceerd,
count(p.polname) AS beleidsregels
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND NOT a.attisdropped
LEFT JOIN pg_policy p ON p.polrelid = c.oid
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
GROUP BY c.relname, c.relrowsecurity, c.relforcerowsecurity
HAVING NOT c.relrowsecurity OR NOT c.relforcerowsecurity OR count(p.polname) = 0
ORDER BY c.relname;
Geen rijen is goed. In onze testdatabase vond hij de twee tabellen die we er expres bij hadden gezet: één zonder RLS, en één met RLS aan maar zonder beleid.
Zet die query niet in een handleiding maar in je testsuite, en laat hem de tabellenlijst uit het schema afleiden in plaats van uit een lijst die iemand bijhoudt. Dat verschil is niet theoretisch: bij booxx stonden er in de eerste versie van die test elf tabellen in terwijl er achtendertig onder RLS vielen, en de grootboek- en banktabellen zaten er geen van alle in. Een lijst die met de hand wordt bijgewerkt, vertelt je precies wat je al wist.
Hoe bewijs je dat het werkt?
Met drie tests, en de eerste is de enige die er echt toe doet:
- Zet een rij weg als klant A, wissel naar klant B, en tel. Niet via je ORM — juist met een rauwe query die het filter bewust overslaat, want dat is de fout die je wilt opvangen. De uitkomst moet nul zijn.
- Vraag zonder klantcontext. Ook nul, en zonder databasefout — dat is gat 2.
- Laat het schema zichzelf aangeven. De query hierboven, als test, over elke tabel met een klantkolom, met een expliciete lijst uitzonderingen die je moet verdedigen.
De eerste test is de test die een vergeten filter in de applicatiecode omzet van een datalek in een lege lijst. Dat is de hele belofte van RLS, en het is precies de klasse fouten die je met code-review niet wegkrijgt: een rauwe query voor een rapport dat te traag was, een achtergrondtaak zonder ingelogde gebruiker, een import die de ORM omzeilt, een relatie die één stap te ver loopt. Die vier werken we uit in multi-tenant vanaf dag één.
Wanneer je RLS níét nodig hebt
RLS is een tweede slot, geen eerste. De scoping in je applicatie blijft gewoon staan; dit vangt op wat daar doorheen glipt.
En het is niet de enige manier om die vangst te organiseren. Ons andere product, Pilot-Next, draait zonder RLS: daar zit de scheiding in de applicatielaag, en de vangst in architectuurtests die de broncode zelf toetsen — een route mag zijn klant niet uit het verzoek halen, en een bulkmutatie op een tabel zonder eigen klantkolom moet op zijn relatie filteren. Dat werkt, en het heeft een ander karakter: het faalt tijdens de build in plaats van tijdens de query, en het dekt alleen wat je hebt bedacht te toetsen. RLS dekt alles wat langs de database komt, ook wat je niet had bedacht — en kost je de vijf gaten hierboven.
De afweging is dus niet “beter of slechter” maar “waar wil je dat de fout opvalt”. Draait er geld of een administratie van iemand anders in je database, dan willen wij dat de database zelf weigert. Voor wie die keuze nog moet maken, staat het bredere overzicht in welk isolatiemodel kies je.
Wil je weten of het beleid in jouw database werkelijk aanstaat? De controlequery uit gat 5 en de eigenaarstabel uit gat 1 kosten samen tien minuten. Komt daar iets uit dat je niet verwachtte, of staat de scheiding nog helemaal in de applicatiecode van een product dat groter is geworden dan gepland — stuur een mail. Wij bouwen SaaS-producten waarin dit vanaf de eerste migratie aanstaat.
Nagemeten op 18-09-2026 in PostgreSQL 17.11, in een lege database met twee klanten. De aangehaalde documentatie: Row Security Policies en Configuration Settings Functions, beide gelezen op 18-09-2026.