Most RAG prototypes are built against a corpus everyone is allowed to read. Then the system goes to a real company, where the corpus is HR files, customer accounts, deal rooms, and one folder the board can see. Retrieval stops being a quality problem and becomes an authorization surface: a top-k lookup that ignores permissions is a search engine that will read private documents aloud to whoever asks the right question.
This is the failure mode we are called in for most often after a pilot goes wider than the pilot team. It is also one of the more mechanical problems in this space — the patterns are known, the trade-offs are measurable, and the tests are cheap. What is expensive is retrofitting it after the index is built.
Decide where the check happens
There are three places you can enforce access, and only one of them is safe.
Post-filtering. Retrieve top-k, then drop chunks the user cannot read. Easy to bolt on, and it does prevent leaks — but it silently destroys recall. If a user is entitled to 5% of the corpus and you retrieve 20 candidates, you may hand the model two passages, or none, and the answer degrades into "I could not find anything" for a document the user owns. Worse, the degradation is invisible in aggregate metrics because it only affects narrowly-permissioned users.
Prompt-level enforcement. Retrieve everything, include the ACLs in context, and instruct the model to only use what the user may see. This is not access control. It is a request. Treat any design that relies on the model declining to quote a document as a leak with extra steps, for the same reason we treat untrusted retrieved text as a data problem rather than a prompting problem.
Pre-filtering at the index. The query carries the caller's identity and group memberships; the vector store restricts the search space before scoring. Top-k is then top-k of what this user can read, so recall behaves the way your evals say it does. This is the one to build.
Denormalize the ACL onto the chunk
Pre-filtering only works if the filter predicate lives next to the vector. That means every chunk carries its own authorization metadata at ingest time, copied down from the source document:
metadata = {
"tenant_id": "acme",
"doc_id": "gdrive:1a2b3c",
"allow_groups": ["eng", "eng-leads"], # principals, not names
"allow_users": ["u_2291"],
"classification": "internal",
"acl_version": 47,
}
Four things matter here.
- Store principal IDs, not display names. Group names get renamed; IDs do not.
- Keep the fields low-cardinality and indexable. Most vector stores (pgvector with a
WHEREclause, Qdrant payload filters, Pinecone metadata filters) are efficient on small keyword sets and slow or unsupported on nested structures. If your filter is a JSON blob, your filter is a table scan. - Model deny rules explicitly or not at all. Union-of-allow is simple and composable. If the source system has deny-overrides-allow semantics, resolve them at ingest into a flat allow list, because you will not reconstruct them correctly inside a query filter.
- Version the ACL. When permissions change upstream, you need to find and update every chunk of that document —
acl_versionplusdoc_idmakes reindexing a targeted operation instead of a full rebuild.
The query side then becomes boring, which is the goal:
retriever = store.as_retriever(
search_kwargs={
"k": 8,
"filter": {
"tenant_id": principal.tenant_id,
"$or": [
{"allow_groups": {"$in": principal.groups}},
{"allow_users": {"$in": [principal.user_id]}},
],
},
}
)
Build the filter from a verified token server-side, inside the retrieval layer. Never from a value the client sent, and never from anything the model produced. In a LangGraph agent, the principal belongs in state that tools read but the model cannot write — if tenant_id is a tool argument the LLM fills in, you have given the model the ability to pick a tenant.
Tenant isolation: filter, namespace, or index
For multi-tenant products the metadata filter is the floor, not the ceiling. Three layouts, in increasing order of isolation and operational cost:
| Layout | Isolation | Cost | Fits |
|---|---|---|---|
| Shared index + tenant filter | Logical; one bad query leaks | Lowest; one index to operate | Many small tenants, internal tools |
| Shared index + per-tenant namespace | Enforced by the store's partitioning | Low; some namespace sprawl | SaaS with hundreds of tenants |
| Index per tenant | Physical; separate credentials possible | Highest; migrations multiply | Enterprise contracts, regulated data |
Namespaces are the usual right answer: the isolation boundary is enforced by the vector store rather than by the correctness of a predicate you assemble in application code, and you can still delete a tenant in one call. Per-tenant indexes are worth it when a contract or auditor requires them — but budget for the fact that every embedding-model migration and every schema change now runs N times, which changes the arithmetic in swapping embedding models without a retrieval regression.
Recall is now per-principal, so measure it that way
A permission filter changes the retrieval distribution, and a single corpus-wide recall number will hide it. Extend your eval set so each case carries a principal alongside the query and the answering passage, then track two things:
- Recall@k per permission tier. Run the same query set as a broadly-permissioned user and as a narrowly-permissioned one. If recall collapses for the narrow principal, your candidate pool is being drained by the filter and you need a deeper
k, a per-tenant partition, or both. - Leak rate, as a hard gate. For each case, assert that no returned chunk is outside the principal's allow set. This is not a scored metric with a threshold; it is an assertion that fails the build.
The leak test is the cheapest high-value test in the whole system. It needs no judge model and no golden answers — just a handful of adversarial principals and a set of queries written to target documents they must not see. Include the obvious attempts ("summarize the Q3 termination letters") and the less obvious ones (queries phrased in the vocabulary of the restricted document, which is where pure semantic similarity pulls hardest).
Run it against the retriever directly, not through the full chain. You want to know whether retrieval leaked, not whether the model happened to omit the leaked text this time. The same principle as scoring agent trajectories rather than final answers: check the step that can be wrong, at the step.
The parts teams forget
Deletions and revocations. A user loses access to a folder at 09:00. Your index still serves those chunks until it is reindexed. Define the staleness window you are willing to defend, and make revocation a push from the source system rather than a nightly crawl if that window is short.
Citations and metadata in the prompt. Filenames, paths, and titles are often more sensitive than the body text. Q4-layoffs-final.xlsx leaks the fact of the layoffs even if the numbers never make it into context. Decide deliberately which metadata fields are prompt-visible and which stay server-side for logging only.
Caches and traces. A semantic cache keyed on the query string alone will serve one tenant's answer to another. Key on the principal's permission set, or scope the cache per tenant. The same goes for tracing: if your traces include retrieved chunk text, your observability tool is now a copy of the corpus with different access rules than the corpus.
Summaries and derived documents. Rollups, extracted entities, and agent memory built from restricted sources inherit the union of their inputs' restrictions. If the derived record does not carry an ACL, it defaults to the most permissive thing in your system, which is exactly wrong.
Order of operations
- Define the principal object — tenant, user ID, group IDs — and resolve it server-side from a verified token.
- Denormalize a flat allow list onto every chunk at ingest, with
doc_idandacl_version. - Choose a tenant layout: filter, namespace, or index. Namespace unless a contract says otherwise.
- Build the filter inside the retrieval layer, from state the model cannot write.
- Add principals to the eval set; baseline recall@k per permission tier.
- Add a leak-assertion suite and wire it into CI as a blocking test.
- Decide the revocation path and the staleness window you can defend.
- Audit caches, traces, prompt-visible metadata, and derived documents for inherited access.
None of this makes the retrieval better. It makes it defensible — and it is considerably less work before the index exists than after.