RAG Permissions: Stop Your Chatbot Leaking Documents Users Should Not See
An internal chatbot that can read everything will eventually quote something the person asking was never allowed to open. The fix is document-level permissions enforced at retrieval time, plus tests that prove it.
Short answer
Store who can see each document on every chunk you index, resolve the caller's identity and groups on the server, and filter the search by those groups before anything reaches the model. Then keep a test suite of users and canary documents that fails the build if anyone sees something they should not.
How a chatbot ends up leaking documents
The usual story is not a clever attack. Someone builds a helpful internal assistant, points the ingestion job at SharePoint, Google Drive or a wiki using a service account that can read everything, and ships it. The source system had careful permissions. The vector index has none.
From then on, anyone who asks the right question can get a summary of the board pack, the salary review or another customer's contract. Nobody broke in. The retrieval layer simply never knew who was asking.
OWASP lists this under vector and embedding weaknesses: inadequate or misaligned access controls in the retrieval store, and cross-context leaks when different groups or tenants share one vector database. Its recommended mitigation is permission-aware vector stores with strict logical partitioning between groups of users.
What does not work
I see the same three shortcuts in most first versions. None of them is a permission system.
- Telling the model not to reveal confidential material. The model has already read it, and a prompt is not an access control.
- Filtering the answer after generation. By then the sensitive text has been in the context window, the logs and possibly a cache.
- Asking the client to send its groups. Anything the browser sends can be edited. Identity has to be resolved on the server from a verified token.
Carry the ACL from the source into every chunk
Permissions have to be captured at ingestion, while you still have the source system's view of the document. For each document, record the principals that can read it: user IDs, group IDs and the tenant. Every chunk inherits the ACL of its parent document, so a paragraph split out of a restricted file stays restricted.
Store those principals as filterable metadata, not as text in the chunk. Keep the source document ID and a last-synced timestamp alongside them, because you will need both when permissions change.
If a source system cannot tell you who can read a document, treat that as a blocker rather than defaulting to "everyone". Default-deny is the only safe failure mode here.
Filter at retrieval time, before the model sees anything
At query time, the server looks up the caller's user ID and group memberships from the identity provider and turns them into a filter on the search itself. Only chunks the caller can read are even candidates for similarity ranking. This is the same point I made in the enterprise RAG patterns piece: authorization belongs in retrieval, and similarity is not an authorization rule.
Most stores support this directly. Pinecone metadata filters accept an $in clause against an array field, with up to 10,000 values in the list, and a filter on an array field matches when any value satisfies it. Azure AI Search documents a security filter pattern using search.in over a group_ids field, and notes that marking that field non-retrievable is not a security mechanism on its own: the filter has to be applied to every query. If your vectors live in Postgres, row-level security can enforce the rule in the database, with a default-deny when no policy exists.
Filtering before ranking also avoids a quieter bug. If you fetch the top 10 chunks and then remove the ones the user cannot see, a junior employee may get two results or none, and the answer quality collapses for exactly the people with the least access.
- Pinecone: Filter by metadata
- Azure AI Search: Security filters for trimming results
- PostgreSQL: Row Security Policies
- RAG Architecture Patterns for Enterprise
// Groups come from the verified session, never from the request body
const principals = await identity.principalsFor(session.userId); // ['user:123', 'group:finance', 'tenant:acme']
const results = await index.query({
vector: await embed(question),
topK: 8,
filter: {
tenant: { $eq: session.tenantId },
allowed_principals: { $in: principals },
},
includeMetadata: true,
});The Postgres catch: who bypasses the policy
Row-level security is a strong option when pgvector already holds your embeddings, but read the bypass rules before relying on it. Superusers and roles with the BYPASSRLS attribute always skip row security, and table owners normally skip it too unless the table is set to FORCE ROW LEVEL SECURITY.
In practice that means the application must query as a dedicated, non-owner role, and the policy should be tested as that role. If your app connects as the same user that ran the migrations, the policy may be doing nothing at all.
Caches, deletions and permission changes
The index is not the only place a document can leak from. Check each of these:
- Semantic or response caches keyed only on the question. Two people asking the same thing with different access must not share a cached answer. Key the cache on the permission set too, or skip it for restricted content.
- Permission changes. When someone leaves a group or a file is restricted, the chunks need their ACL updated on the next sync. Know how long that window is and whether it is acceptable.
- Deletions. A deleted source document should disappear from the index, the cache and any stored conversation context, not just stop being re-ingested.
- Logs and traces. Retrieval logs that store full chunk text become a second copy of the corpus with weaker access control. Log document IDs and scores, not content, where you can.
Test permissions like a security control
A permission bug in RAG is silent. The answer still sounds helpful. So the tests have to look for it on purpose.
Build a small fixture: three or four test users with different groups and tenants, and canary documents that each contain a unique, searchable phrase. Then assert, in CI, that each user can retrieve their own canaries and never anyone else's, through the same API path the chatbot uses. Add a test for a user who was just removed from a group, and one for a deleted document.
In production, keep an append-only record of which document IDs were retrieved for which user and request. OWASP recommends immutable logs of retrieval activity, and when someone asks "could this person have seen that file?", those logs are how you answer.
- Every chunk has tenant and principal metadata, and ingestion fails closed when it is missing.
- Identity and groups are resolved server-side on every request.
- The filter is applied inside the search call, not after it.
- Caches are keyed by permission set or disabled for restricted content.
- Canary documents and multi-user tests run in CI.
- Retrieval logs record document IDs per user and request.
When to bring in help
If you have one source system with simple groups, the steps above are a few days of careful work. It gets harder when permissions come from several systems, when you need tenants fully isolated, or when the whole thing has to run on infrastructure you own because the documents cannot leave your network.
That is the work in my LLM Private Cloud package: a private deployment with retrieval over your internal knowledge sources, permission filtering built into the retrieval path, and the tests and observability to prove it. If your setup does not fit a package, a custom engagement starts with mapping where your permissions actually live.