RAG
RAG-lek tussen tenants: de row-level policy die het stopte
Een tenant vroeg naar boeterente en kreeg een alinea uit het contract van een ander. Na zeven regels incidentrapport was de oorzaak duidelijk: retrieval zonder tenant-grens op de database.

Dinsdag om 11:14
In het ticket stond: "Waarom citeert jullie bot de betaalvoorwaarden van onze concurrent terug naar ons?"
Er zat een screenshot bij. Onze retrieval-augmented agent had, op een vraag over hoe boeterente werd afgehandeld, een keurige samenvatting van twee alinea's geleverd. De samenvatting klopte. Het contract dat hij samenvatte hoorde bij een andere klant.
Om 11:17 hebben we de agent offline gehaald. Om 18:40 stond hij gepatcht weer live. De fix was een Postgres-policy van veertien regels. Het incidentrapport dat we naar de getroffen tenant stuurden was zeven regels lang, want er viel niet meer te zeggen.
Deze post loopt het hele verhaal door: wat er stuk ging, hoe het rapport eruitzag, en de row-level policy die deze categorie bug afsloot.
Het incidentrapport van zeven regels
Incident: Cross-tenant content disclosure in RAG response
Detected: 2026-05-12 11:14 CEST via customer support ticket #4118
Scope: 1 affected tenant, 1 query, response contained 2 paragraphs sourced from another tenant's draft contract
Cause: Tenant isolation enforced only in application code; an off-path retrieval call accepted tenant_id=None and skipped the filter
Containment: Agent disabled at 11:17 CEST; affected response and source chunks quarantined
Fix: Row-level security on doc_chunks with FORCE, session-bound app.tenant_id GUC, rollout completed 18:40 CEST
Customer impact: 2 paragraphs of a non-executed draft, no PII, no financial data. Notification to both tenants sent 19:02 CEST.We hielden het bewust kort. Een lang rapport is een manier om een klein bugje achter taal te verstoppen. Zeven regels dwingt eerlijkheid af over wat er gebeurde en wat we veranderd hebben.
Wat onze RAG eigenlijk deed
De stack is niets bijzonders. Klantdocumenten landen in object storage, worden in chunks geknipt, krijgen embeddings, en gaan een Postgres-tabel in met pgvector. Bij een query embedden we de vraag van de gebruiker, draaien we een k-NN search, en proppen we de top-k in de prompt. Multi-tenant vanaf dag één. Elke chunk-rij heeft een tenant_id. De retriever filterde daarop. Zo:
SELECT chunk_id, content
FROM doc_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 8;Die query is prima. Het probleem is dat het niet de enige query is.
Ergens in de geschiedenis van de codebase was een intern endpoint gebouwd waarmee je kon previewen hoe de retriever een document rangschikte. Die riep dezelfde Python-class aan, maar gaf tenant_id=None door voor "preview als superuser." Zodra de retrieval-class None zag, haalde hij de filter weg.
Daarna heeft iemand dat preview-endpoint aan een andere code path gekoppeld. Daarna ging de agent het gebruiken voor een specifiek soort fallback waar de oorspronkelijke auteur niet op had gerekend. Daarna stelde een klant een vraag waarvan de top-8 dichtstbijzijnde buren toevallig twee chunks uit het concept van een andere tenant bevatten.
Niemand schreef een regel code die zei "lek dit." Het lekte omdat het enige dat tussen tenants stond een Python-parameter was met None als default.
Filteren in applicatiecode is geen grens
Dit is het stuk waar je even bij stil moet staan. Als je enige tenant-isolatie een WHERE-clause is die je applicatie zelf moet toevoegen, dan heb je tenants niet geïsoleerd. Je hebt ze beleefd gevraagd om niet met elkaar te praten.
Alles wat de applicatielaag omzeilt slaat de filter over: een intern script, een debug-endpoint, een Jupyter notebook die een van je engineers tegen de prod-replica draait, een toekomstige beheerder die docs leest waarin staat "zet tenant_id=None om de filter over te slaan," een refactor die een tweede code path introduceert waarvan je niet meer wist dat het bestond.
Dit is precies het patroon dat OWASP in zijn 2025 LLM Top 10 heeft gecatalogiseerd onder sensitive information disclosure: het model is het lek niet, het data-pad dat het voedt wel. De mitigatie die werkt is structurele toegangscontrole op de storage-laag, niet instructies in een prompt.
Het is ook dezelfde les die steeds opduikt in writeups over hoe leveranciers taalmodellen tussen producten in toom houden. De interessante grens is niet wat het model verteld wordt. Het is wat het model mag zien.
De row-level policy die het stopte
Postgres heeft row-level security sinds 9.5. In combinatie met pgvector werkt RLS prima samen met vector search, want vector search is gewoon een query op een tabel. De policy filtert voordat de ORDER BY de rij ooit ziet, dus de planner hoeft chunks buiten de tenant niet eens te overwegen.
Hier is de vorm van de migratie die we hebben uitgerold, teruggebracht tot wat ertoe doet:
-- 1. Make tenant_id non-null and indexed.
ALTER TABLE doc_chunks
ALTER COLUMN tenant_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS doc_chunks_tenant_idx
ON doc_chunks (tenant_id);
-- 2. Turn RLS on, and force it for table owners too.
ALTER TABLE doc_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE doc_chunks FORCE ROW LEVEL SECURITY;
-- 3. The policy. A session may only see rows whose
-- tenant_id matches the GUC set on connection.
CREATE POLICY tenant_isolation ON doc_chunks
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);De connection pool van de agent zet app.tenant_id nu bij het begin van elke transactie, afgeleid van de geauthenticeerde request, niet uit een functie-argument:
async def with_tenant(conn, tenant_id: UUID) -> None:
# `true` makes the setting local to the current transaction.
await conn.execute(
"SELECT set_config('app.tenant_id', $1, true)",
str(tenant_id),
)Hetzelfde stukgelopen admin-endpoint zou vandaag nul rijen teruggeven. Geen "minder rijen." Nul. Er bestaat geen waarde van het Python-functie-argument waardoor de query data van een andere tenant kan zien, want het Python-functie-argument speelt geen rol meer in de toegangsbeslissing.
RLS zonder FORCE is een suggestie. Table owners omzeilen policies standaard, en de meeste applicatie-rollen verbinden als de table owner. Zet FORCE ROW LEVEL SECURITY erbij, en check daarna welke rollen nog BYPASSRLS hebben. Wij geven dat aan precies nul applicatie-rollen. Migraties draaien onder een aparte rol met een eigen audit-trail.
De test die ons gepakt zou hebben
Deze test hadden we eerder niet. Nu wel. Twee tenants, twee chunks per stuk, draai de retriever als tenant A, check dat er geen content van tenant B terugkomt, en doe daarna het omgekeerde.
@pytest.mark.asyncio
async def test_retriever_cannot_cross_tenants(pool):
a, b = uuid4(), uuid4()
await seed_chunk(a, "Late fees: 2% per month after day 30.")
await seed_chunk(b, "Late fees: 5% per month after day 14.")
async with pool.acquire() as conn:
await with_tenant(conn, a)
hits = await retrieve(conn, "what is the late fee", k=10)
contents = [h.content for h in hits]
assert any("2%" in c for c in contents)
assert not any("5%" in c for c in contents)Het punt van deze test is niet om aan te tonen dat de policy één keer werkt. Het is om hard te falen op de dag dat iemand de connection pool refactort en vergeet with_tenant aan te roepen. Zonder RLS lekt die refactor stilletjes. Met RLS slaat de test rood, want de agent haalt opeens helemaal niets meer op. "Niets ophalen" is een veel goedkopere faalmodus dan "verkeerde tenant ophalen."
Wat we hielden, en wat we eruit sloopten
We hielden de WHERE-clause op applicatieniveau. Defence in depth kost hier niets, en het laat de query planner rijen buiten de tenant overslaan, ook als statistieken verschuiven. De RLS-policy is de grens. De WHERE-clause is een hint.
We hebben het patroon "tenant_id=None betekent superuser" overal uit de codebase gesloopt. Er bestaat geen superuser van tenant-data binnen de applicatie. Als iemand bij ons de chunks van een tenant moet inkijken om te debuggen, dan verbindt diegene met zijn eigen database-rol, die een policy heeft waarin expliciet staat welke tenants hij ondersteunt, en elke query die hij draait wordt gelogd op zijn naam.
We hebben evaluatiefixtures ook naar een apart schema verplaatst. De eval-set leefde vroeger in dezelfde tabel met tenant_id='eval'. Dat is het soort schattige idee dat tanden krijgt. Nu staat eval-data in eval_chunks, met een eigen retriever, en die kan niet per ongeluk bereikt worden door code die tenant-gescoopt is.
De audit log die we op dag één hadden willen hebben
Sinds de fix schrijft elke retrieval één logregel met de aanvragende tenant_id, een hash van de query-embedding, de IDs van de teruggegeven rijen, en de tenant_id van elke rij. Met RLS aan is die laatste kolom per definitie constant en gelijk aan de aanvrager. De log wordt zo een tripwire in plaats van een debug-hulpje: elke regel waar de twee tenant_ids niet overeenkomen is per definitie een bug in onze policy. We alerten erop. Tot nu toe: nul hits.
Het andere dat we veranderden: incidentrapporten naar tenants blijven kort. De verleiding als er iets misgaat is om drie pagina's vol te schrijven over hoe serieus je security neemt. De tenant zit niet te wachten op drie pagina's. Die wil weten wat er gebeurd is, wat je veranderd hebt, en of het opnieuw kan. Zeven regels is ruim genoeg om alle drie te zeggen.
Het patroon, breder getrokken
Rol je een RAG-agent uit voor een multi-tenant workload, vraag jezelf dan dit af: als ik vandaag elke WHERE-clause uit mijn applicatiecode haal, wordt dan data van een andere tenant bereikbaar? Is het antwoord ja, dan dwingt de database geen isolatie af. De applicatie doet dat, tot het moment dat ze het niet doet.
Dit gaat verder dan RAG. Dezelfde logica geldt voor achtergrondjobs die aan klantdata komen, voor admintools, voor evaluatieharnassen voor taalmodellen, voor alles wat uit een gedeelde tabel leest. RLS aan het begin van een project kost ongeveer een middag. Het er achteraf indraaien na een lek kost het lek zelf, plus het vertrouwen dat je verbrandt met het schrijven van de notificatiemail.
Als het weghalen van de WHERE-clause in je applicatie data van een andere tenant onthult, dan dwingt je database geen isolatie af. RLS met FORCE is de enige fix die een refactor overleeft.
Draai je een RAG-agent op gedeelde infrastructuur
Toen we eerder dit jaar de document-retrieval agent bouwden voor een Nederlands vastgoedbedrijf, liepen we precies hiertegenaan: de neiging om tenant-logica in de tool-laag van de agent te zetten, waar het makkelijk te overzien was, in plaats van in de database, waar het lastig te omzeilen was. Uiteindelijk hebben we het opgelost zoals hierboven beschreven, en elke nieuwe AI-agent die we opleveren heeft RLS standaard aan.
De audit van vijf minuten die je vandaag kunt draaien: pak één productie-query die aan tenant-data komt, plak hem in een SQL-client, draai hem zonder tenant-filter tegen een non-prod replica, en kijk wat eruit komt. Krijg je rijen terug, dan heb je een bug. De grootte van de bug is de grootte van die result set.
Kern
Als het weghalen van de WHERE-clause in je applicatie data van een andere tenant onthult, dan dwingt je database geen isolatie af. RLS met FORCE is de enige fix die een refactor overleeft.
FAQ
Maakt row-level security pgvector-queries trager?
In onze workload niet noemenswaardig. RLS voegt een predicate toe dat de planner via onze WHERE-clause al moest evalueren. De tenant_id-index maakt beide even goedkoop. Meet op je eigen data voordat je overhead aanneemt.
Waarom niet elke tenant zijn eigen schema of database geven?
Dat werkt, maar het schuift de grens naar ops: migraties, back-ups en connection pooling vermenigvuldigen allemaal met het aantal tenants. RLS houdt één schema en één pool, en dwingt nog steeds isolatie af op rijniveau.
En als je vectors in een managed vector-DB staan, niet in Postgres?
Check of de leverancier per-namespace API keys of gescoopte tokens ondersteunt, en behandel metadata-filters als applicatielogica, niet als grens. Als isolatie alleen in een filter zit die je bij de query meegeeft, dan heb je dezelfde bug als wij.
Kan het taalmodel data die al in de context staat alsnog lekken?
Ja. RLS stopt cross-tenant retrieval, niet verwarring binnen de context. Houd één tenant per prompt, en meng nooit tenants in dezelfde agent-sessie, ook niet voor evaluatie of batching.
Hoe heb je de getroffen tenant geïnformeerd?
Dezelfde dag, per mail, met het rapport van zeven regels in de bijlage en een contactpersoon op naam voor vervolgvragen. We hebben de tenant van wie de data lekte diezelfde dag op dezelfde manier ingelicht, met hetzelfde rapport.