SaaS-architectuur
Rol per klant of sessievariabele voor RLS in PostgreSQL
Row-level security in PostgreSQL 17 met een rol per klant of een sessievariabele, gemeten: SET ROLE bij 10.000 rollen, PgBouncer, injectie en definer-functies.
Row-level security moet weten welke klant het vraagt. Er zijn twee manieren om dat aan PostgreSQL te vertellen: een sessievariabele (set_config('app.tenant', …), met een beleid op current_setting), of een databaserol per klant (SET ROLE, met een beleid op current_user). De meeste handleidingen kiezen de variabele en doen rollen in één zin af: “onhandig als je veel klanten hebt”. Geen van alle zegt wat dat “onhandig” kost.
We maten beide op PostgreSQL 17.10, in een wegwerpcontainer met standaardinstellingen: 311.205 facturen over 100 klanten met gegevens (de grootste 60.000, een middelgrote 1.200) en 1.000 tot 10.000 klantrollen. Er is ook een derde variant, en die blijkt het meest uit te maken: niet van rol wisselen, maar inloggen als de klant.
Rol per klant of sessievariabele: het korte antwoord
| Sessievariabele | SET ROLE per klant (gedeelde login) | Login per klant | |
|---|---|---|---|
| Snelheid van een query (tellen grootste / laatste 50 middelgrote) | 3,4–3,6 ms / 0,15 ms | 3,4 ms / 0,19 ms | niet apart gemeten |
| Van klant wisselen | 0,05–0,11 ms | eerste wissel in een sessie 19 ms (1.000 rollen), 97 ms (5.000), 211 ms (10.000); daarna 0,1–0,2 ms | nieuwe verbinding |
| Klant toevoegen | een rij invoegen | CREATE ROLE + GRANT; de volgende wissel in elke sessie betaalt de eerste-wisselprijs opnieuw | CREATE ROLE … LOGIN met een wachtwoord |
| Achter PgBouncer (transaction mode) | SET lekt, SET LOCAL niet | SET ROLE lekt, SET LOCAL ROLE niet | één pool per klant |
| SQL-injectie kan van klant wisselen | ja | ja | nee |
Binnen een SECURITY DEFINER-functie | nog steeds de klant | de eigenaar van de functie | de eigenaar van de functie |
De querytijden zijn voor het tellen van de facturen van de grootste klant en het ophalen van de laatste 50 van de middelgrote — dezelfde indexen, dezelfde orde van grootte. Waar de twee aanpakken verschillen, is alles rond de query.
Wat kost SET ROLE bij duizenden klanten?
De eerste SET ROLE in een sessie is traag, en groeit met het aantal rollen waarvan de loginrol lid is. Telkens drie verse sessies:
| Klantrollen toegekend aan de app-login | Eerste SET ROLE | Tweede SET ROLE |
|---|---|---|
| 1.000 | 18,1–19,3 ms | 0,11–0,21 ms |
| 5.000 | 96,7–96,9 ms | 0,12–0,13 ms |
| 10.000 | 208,8–212,6 ms | 0,13–0,16 ms |
Dat is ongeveer 20 microseconde per lidmaatschap, één keer per verbinding — PostgreSQL rekent uit waarvan de sessie lid is en onthoudt dat. Met een verbindingspool die verbindingen openhoudt, kun je daarmee leven. Het addertje zit in de levensduur van die cache. We hielden een sessie met 5.000 lidmaatschappen open, warmden hem op, en maakten intussen vanuit een andere sessie één nieuwe klant aan (CREATE ROLE, GRANT … TO app). De volgende SET ROLE in de eerste sessie kostte weer 87 ms. Elke nieuwe klant laat de volgende wissel in elke open verbinding de volle prijs betalen. Ook wijzigingen aan rollen die niets met de app-login te maken hebben tellen mee, tegen een lagere prijs: na een losse CREATE ROLE of een wachtwoordwijziging van een andere rol kostte de volgende wissel 8,3–8,4 ms in plaats van 0,1.
Ter vergelijking: SET app.tenant kostte de eerste keer in een sessie 0,10–0,11 ms en daarna 0,05–0,08 ms, ongeacht het aantal klanten.
Het aanmaken van de rollen zelf is het probleem niet: 1.000 rollen met hun toekenningen kostten 0,2 seconde, 9.000 extra 1,9 seconde.
Gedragen prepared statements zich anders?
Een beetje. Een prepared statement zonder parameters (select … order by issued_on desc limit 50) krijgt altijd een generiek plan — pg_prepared_statements toonde in beide gevallen alleen generieke plannen — dus de planner weet niet voor welke klant hij plant. Tweehonderd keer uitvoeren voor dezelfde klant kostte 0,18 ms per keer met de variabele en 0,22 ms met een rol (het rolbeleid zoekt in tenant_roles; de variabele is een cast). Bij elke uitvoering wisselen tussen twee klanten: nog steeds 0,18 ms met de variabele, 0,30 ms met rollen. Een opgeslagen statement dat van row security afhangt, hoort bij de rol waarvoor het is voorbereid; na een rolwissel analyseert, herschrijft en plant PostgreSQL het opnieuw, en een gewijzigde variabele doet dat niet. Het nieuwe plan heeft dezelfde vorm, dus een rol per klant lost het bevroren plan uit PgBouncer en RLS niet op — hij betaalt alleen bij elke wissel voor een nieuw plan.
Lekt SET ROLE achter PgBouncer?
Precies zoals SET. Met PgBouncer 1.25.2 in transaction mode en één serververbinding deed client A SET ROLE t1 en verbrak de verbinding. Client B, een nieuwe client die niets instelde, kreeg current_user t1 en 60.000 facturen — die van de grootste klant. Met SET LOCAL ROLE t1 binnen een transactie was de volgende client weer app en zag hij 0 rijen.
PgBouncer zegt zelf waarom: in transaction mode draait hij zijn resetquery niet, “because in that mode, clients must not use any session-based features”. De regel uit ons PgBouncer-artikel geldt onverkort: klantcontext alleen per transactie, of het nu een variabele is of een rol.
Beschermt een rol per klant tegen SQL-injectie?
Niet als de applicatie van rol wisselt. SET ROLE wordt getoetst aan de sessiegebruiker, en de loginrol van de applicatie moet lid zijn van elke klantrol om ernaar te kunnen wisselen. Als klantrol t5 lukte SET ROLE t6 gewoon en gaf de 10.000 facturen van klant 6; RESET ROLE ging terug naar de loginrol. Wie SQL kan uitvoeren op die verbinding, kiest zelf een klant — net als met set_config('app.tenant', '6', false), dat met de variabele hetzelfde deed.
Anders wordt het als de klant zelf de login ís. Ingelogd als t5 gaf SET ROLE t6 permission denied to set role "t6", bleef RESET ROLE op t5, en werd SET ROLE app ook geweigerd. Dat is de enige echte beveiligingswinst van rollen: een geïnjecteerde opdracht kan zijn klant niet verlaten. Daar hangt een prijs aan. PgBouncer houdt een pool per gebruiker en database — de poolgrootte is het maximum aantal serververbindingen “per user/database pair” — dus duizend klanten zijn duizend pools, en elke klant met een lopend verzoek heeft een eigen serververbinding nodig, tegen een standaard max_connections van 100. En de applicatie heeft nog steeds het wachtwoord van elke klant: dit beschermt tegen geïnjecteerde SQL, niet tegen een gekraakte applicatie.
Wat gebeurt er in een SECURITY DEFINER-functie?
Die draait als zijn eigenaar, en current_user wordt die eigenaar. We maakten een SECURITY DEFINER-functie die facturen telt, met de applicatierol als eigenaar. Aangeroepen als klantrol t5 gaf hij 0 — binnen de functie zocht het beleid de eigenaar op, niet de klant. Met de sessievariabele gaf dezelfde functie de 12.000 facturen van klant 5: de variabele hoort bij de sessie, niet bij de rol. Was de eigenaar een superuser geweest, een rol met BYPASSRLS of de tabeleigenaar zonder FORCE ROW LEVEL SECURITY, dan had de functie niet nul maar alle klanten gezien.
Welke kies je?
- Sessievariabele met
SET LOCALofset_config(…, true)voor bijna elke SaaS: goedkoop wisselen, goedkoop klanten toevoegen, één pool, werkt in definer-functies. De zwakte — SQL op de verbinding kan de klant veranderen — deelt hij metSET ROLE. SET ROLEper klant over een gedeelde login kost een eerste-wisselboete die met elke klant groeit en bij elke aanmelding opnieuw ingaat, en koopt geen bescherming die de variabele niet ook geeft. In onze metingen wint hij nergens. Wat hij wel biedt, zijn rechten per klantrol en beleid metTO rol— nuttig als klanten op verschillende abonnementen verschillende tabellen mogen zien, en dat hebben we niet getest.- Een login per klant is de enige variant waarin een geïnjecteerde opdracht zijn klant niet kan verlaten. Hij past bij een product met tientallen grote klanten en een pool per klant, niet bij een product met duizenden kleine.
Wat we niet hebben gemeten
Rollen met eigen tabelrechten (alle klantrollen erven hier dezelfde rechten van één groepsrol), hoe pg_stat_statements statements per rol telt, pg_dump en catalogusgrootte bij 10.000 rollen, beheerde diensten die het aanmaken van rollen beperken, en de opstarttijd van een verbinding per loginrol.
Bronnen: de PostgreSQL 17-documentatie over SET ROLE (rechtencontrole tegen de sessiegebruiker) en row security policies, en de PgBouncer-configuratiereferentie (server_reset_query, default_pool_size), alle gelezen op 1 oktober 2026. Alle metingen zijn van onszelf, op PostgreSQL 17.10 en PgBouncer 1.25.2, op 1 oktober 2026.