RAG Access Control: Permission-Aware Retrieval for Internal Documents
A RAG assistant leaks when its permission check runs after retrieval. The employee is signed in, the search covers every indexed chunk, and restricted text reaches the model before anything is removed. This post covers the checks that matter, plus two points that are easy to miss: filtered vector search gets worse exactly for the users with the narrowest access, and the person who asked is not always the only person who reads the answer.
Filter before retrieval, and know what it costs
Microsoft's secure multitenant RAG guidance has orchestration apply security filtering so retrieval returns only data the caller may access. Filtering results after the search is weaker for security, since restricted chunks were already retrieved into application memory. It is also weaker for quality.
A benchmark on 100,000 vectors with a tenant ID measured this. Taking the top 100 hits and then filtering recalled 19% of the true results when the filter matched 1% of the corpus, and 10.5% at 0.1%. Filtering inside the graph search kept recall at 100% in every case, but median latency rose from 0.44 ms at 50% selectivity to 17 ms at 1% and 74 ms at 0.1%. Two things follow for an internal assistant. A user with narrow access (a contractor, a new hire, a single-team employee) is the worst case for both approximate-search quality and latency. And you should measure recall per access profile, because an average over all users hides the people for whom the assistant silently returns nothing useful. Separate indexes or namespaces per tenant avoid the problem when tenants are large and few. Per-document ACLs inside one tenant cannot be partitioned that way, so test the in-index filter.
Increase candidate depth only inside the authorized set. Never loosen the filter to repair relevance.
The reader is not always the asker
Per-user filtering assumes one person sees one answer. In a Slack channel or group chat, everyone present reads the reply, and the retrieval itself can be the leak. PromptArmor showed that an attacker who could not read a victim's private channel could create a public channel containing only themselves and post an instruction there. Slack AI retrieves messages from public channels a user has not joined, so when the victim asked for their API key, the planted instruction entered the same context and the answer rendered a link carrying the key from the private channel. Slack's security team judged it intended behavior, since public channel messages are searchable by every workspace member (PromptArmor). The permission boundary must cover everything the assistant retrieves, not only what the asker could read. For a decision on whether content from one conversation may appear in another, these rules are a safe starting point:
- Content from the current conversation is always shown.
- In the asker's own DM with the bot, anything the asker can read is shown.
- Everywhere else, only content from public channels is shown, checked live, so a channel made private since the content was stored stops counting.
- Even public content is withheld when any reader is a guest or from another organization.
- If Slack cannot say who is in the conversation, nothing crosses over.
Cache conversation type and membership for a short, known window, and write the number down. A guest added to a channel can see cross-conversation content until the cache expires, and a known window is better than a surprise. If your assistant answers in shared spaces, the access set to filter on is the intersection of everyone who will read the reply, and the revocation window is whatever your cache TTL says.
Carry permissions through ingestion
Store an authorization envelope with every chunk: a stable source document ID, tenant, allowed group IDs (not display names), and a permission version. Avoid long per-user lists. Pinecone's multitenancy guidance recommends namespaces per tenant, and advises access-control groups such as organization, project or role over large lists of individual user IDs in metadata filters. Write content and permissions as one versioned operation, or mark the document unavailable until both land. A short outage is better than a window where new text is searchable by everyone.
{
"chunk_id": "doc-482:7",
"source_document_id": "doc-482",
"tenant_id": "org-19",
"allowed_group_ids": ["finance", "executive"],
"permission_version": 41
}
Resolve the caller's groups on the server from your identity provider for each request, combine them with the semantic query as a mandatory filter, and treat missing or stale permission metadata as a deny. The model never writes or edits the filter. Instructions such as "do not show finance documents to non-finance users" are not access control, because the model should never receive that text.
Revocation, caches and logs
A delayed grant annoys a user. A delayed removal exposes data. Subscribe to source change events with a reconciliation job as backup, update every chunk of a changed document, and measure the time from revocation to retrieval denial against a stated window. Cache keys for answers must include tenant, effective groups and a permission epoch, and an ACL change must purge related entries. Logs should record the decision (user, document IDs, permission versions, result) and not the protected text. Citations should come from the authorized hit set and link through an access-checking route, not a raw object URL.
The two-user test
Use one allowed user, one denied user, and a restricted document with a harmless unique marker. The Azure GPT-RAG issue on end-to-end authorization tests asks for exactly this pattern across chat, citations, tools and logs, and for a failure to block release. Run it against every route that can retrieve content, including batch jobs, agent gateways and alternate UIs:
- The denied user cannot surface the marker by title, quote, synonym or broad question.
- A cache warmed by the allowed user does not answer the denied user.
- Removing the allowed user's group blocks retrieval within the stated window, and re-adding it restores access without duplicate chunks.
- Missing ACL metadata, an identity lookup failure and a stale permission version all deny.
- The logs prove the denial without containing the marker.
Add a group-chat case if your assistant lives in one: the allowed user asks in a channel that includes the denied user, and the answer must not contain the marker.
References
- Microsoft secure multitenant RAG architecture
- Azure AI Search document-level access control
- Metadata filtering in vector search: post-filter loses results, measured
- Pinecone multitenancy guidance
- Azure GPT-RAG issue 591
- PromptArmor: Data Exfiltration from Slack AI via indirect prompt injection: private-channel secrets leaked through a public channel the victim never joined.