A vector index does not know your tenants. If retrieval runs as a superuser and similarity is the only filter, your RAG feature is a cross-tenant leak with good UX. This playbook builds retrieval that inherits row-level security — the caller's identity filters the vectors — then attacks it with adversarial probes until nothing leaks.
Tier: Advanced · Workflow area: Governed Data Access · ~45 minutes
The failure mode is quiet and catastrophic: a user asks the assistant a question, retrieval pulls the nearest neighbors from the whole corpus, and a competitor's contract terms end up paraphrased in the answer. Entitlement enforcement must live in the database, where it already exists as RLS — not in application filters someone will forget, and never in the prompt. The DBA who can prove "retrieval cannot see what the caller cannot see" owns a control every AI feature in the org needs.
Put a tenant key on the vector table and switch RLS on.
ALTER TABLE doc_chunks
ADD COLUMN tenant_id int NOT NULL;
ALTER TABLE doc_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE doc_chunks FORCE ROW LEVEL SECURITY; -- binds owner too
CREATE POLICY tenant_isolation ON doc_chunks
USING (tenant_id = current_setting('app.tenant_id')::int);
FORCE matters: without it the table owner bypasses every policy — the classic silent hole. The policy reads the caller's tenant from a session setting the application sets per request.
Make the retrieval path carry identity — no privileged detours. The retrieval role is unprivileged and non-owner; every connection sets the tenant before querying:
CREATE ROLE rag_reader LOGIN PASSWORD '...';
GRANT SELECT ON doc_chunks TO rag_reader;
-- per request, from the app or MCP layer:
SET app.tenant_id = '42';
SELECT chunk_id, content, 1 - (embedding <=> $1) AS score
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 8;
The query itself contains no tenant filter — that is the point. RLS injects it, so a forgotten WHERE clause in some future code path cannot leak.
Read the plan: RLS meets ANN in a specific, checkable way. Run EXPLAIN (ANALYZE) on the retrieval query as rag_reader. The HNSW scan returns nearest neighbors first, and the RLS predicate filters them after — so a small tenant may get 2 results back from LIMIT 8. Fixes to evaluate, with measurements: raise hnsw.ef_search so more candidates survive filtering; use pgvector 0.8+ iterative scans; or, for hard tenant walls at scale, partition by tenant so each tenant's index is separate. Ask AI to lay out the trade-offs for your tenant-size distribution, then verify its claims against your EXPLAIN output — this recall-under-RLS interaction is the technical heart of the playbook.
Build the leak-probe battery — ten attacks, written down before you run them. Have AI draft probes, then add your own; the battery must include at minimum: querying with no app.tenant_id set (must error or return zero rows, never default open); a forged tenant id outside the caller's grant (app-layer check must catch); connecting as the table owner without FORCE (regression probe); a semantic query targeting a known other-tenant document verbatim ("summarize the Acme renewal terms"); SQL through any raw-query MCP tool attempting SET app.tenant_id; a chunk whose text contains another tenant's data planted by a bad ingest (this probe tests your pipeline, not RLS); the app's connection pool reusing a session without resetting the setting; retrieval during a tenant migration; a JOIN path around the policy via an unprotected side table; and an EXPLAIN-based oracle probing row counts.
Run the battery as a real client and score it. Execute every probe through the actual application or MCP path, not psql-as-admin. Record per probe: what was attempted, what came back, verdict. An honest first run typically fails 1–2 probes — the connection-pool reset and the unprotected side table are the usual culprits. Sample finding worth framing: probe 7: pool reused session, previous tenant's setting persisted — 8 cross-tenant chunks returned. Fix: SET LOCAL inside per-request transaction. Re-run: 0 rows. SET LOCAL scoped to the transaction is the standard fix — settings die with the transaction, so pool reuse is safe.
Make the battery a regression suite and document the guarantee. Script all ten probes, run them in CI against staging on every change to schema, policies or retrieval code. Publish the guarantee in writing: which tables carry RLS, what identity flows from where, what the probes cover, and the last green run. That document is what you hand the security review — and pb-52's audit procedure will cite it.
SESSION_CONTEXT(); same probe battery, with special attention to whether elevated app roles bypass the predicate.The retrieved chunks flow onward into an LLM prompt — retrieval-side enforcement is void if the app logs full prompts to a shared log store, caches answers across users, or lets the model return raw chunks verbatim to a different session. Trace the entitlement boundary all the way to the response, not just to the SELECT.
Entitlement-aware RAG is row-level security doing its oldest job on a new access path — enforced in the engine, carried by identity, and believed only after adversarial probes fail to break it. Ten scripted attacks, rerun forever: that is what "our AI can't leak tenant data" means when you say it in a security review.