NeoMonarch.
Start a build
All insights

AI engineering · 11 min read

Permission-aware RAG for enterprise content

Carry source permissions into the index, filter before you rank, cite what you used, and test for leaks as hard as you test for relevance.

By Omprakash Kuppurajah · NeoMonarch

Photo: Jan Antonin Kolar on Unsplash

Most retrieval-augmented generation demos run against a folder of public PDFs. Production RAG runs against SharePoint sites, Confluence spaces, ticketing systems, contract repositories and ERP notes, and every one of those sources already has an answer to the question "who is allowed to read this?". The moment you copy that content into a vector index, the answer is gone unless you carry it across on purpose. The assistant then becomes the easiest way in the company to read the board pack, the salary review or another customer's pricing.

OWASP lists this as a named risk. Its entry for LLM08:2025 Vector and Embedding Weaknesses calls for fine-grained access controls and permission-aware vector stores, and warns that in multi-tenant setups embeddings from one group can be retrieved for another. That matches what we see: the leak rarely comes from a clever attack. It comes from an ingestion job that indexed everything with a service account and a retrieval call that never asked who the user was.

This article sets out how we build RAG that respects source permissions end to end: how access control lists are captured at ingestion, enforced at retrieval, combined with hybrid search, surfaced as citations, kept fresh, and tested. It is the design behind our Retrieve accelerator, and the same rules apply whichever search engine or vector database you choose.

Permissions belong in the index

The first decision is where authorisation happens. There are three options. You can ask the source system at query time whether the user may read each candidate document; you can store permissions alongside each chunk and filter in the index; or you can build a separate index per user or per group. Per-user indexes are simple to reason about and impossible to run at any scale, because a single document shared with 4,000 people is embedded 4,000 times. Live checks against the source are the most accurate and the slowest, and they turn every query into dozens of calls to systems that were never sized for that traffic.

For most estates we store permissions in the index and treat the source system as the authority we reconcile against. Each chunk carries the principals allowed to read its parent document, plus the metadata needed to explain and refresh that decision: source system, source document ID, ACL version or etag, and the time the ACL was last read. Microsoft's document-level access guidance for Azure AI Search describes the same two patterns, native ACL ingestion from sources such as ADLS Gen2 and SharePoint and plain string "security filters" for custom identity systems, and notes that the native path still has a lag before permission changes are recognised. That lag is a property of every index-side design and we come back to it below.

Ingestion pipeline diagram. Source systems (document stores, wikis, ticketing) feed a connector that reads both content and access control lists. Content goes through parsing and chunking, then embedding; permissions go through principal resolution, which expands nested groups into identity provider group IDs. Both streams meet at the index writer, which stores each chunk with its text, vector, allowed principals, source ID and ACL version in a single search index. A change feed from the sources triggers re-reads of content and permissions.
Figure 1. Content and permissions travel together from the source connector to the index, so every chunk carries its own access list.

Resolving principals

The hard part of ingestion is not reading an ACL. It is turning it into something you can match at query time. Source systems express access in their own vocabularies: SharePoint groups, Confluence space permissions, Jira project roles, "everyone in the organisation", explicit deny entries, inherited folder permissions. We normalise all of these to identity-provider group and user IDs, the same identifiers that appear in the user's token, and we keep the original expression for audit.

Two rules save a lot of pain. First, store groups, not expanded users. If a document is readable by "Finance EMEA" with 300 members, store the group ID and expand the user's memberships at query time; otherwise every joiner and leaver forces a re-index. Second, fail closed. A document whose ACL cannot be parsed, or that contains a deny rule the index cannot represent, should be indexed as readable by nobody and flagged, never indexed as readable by everyone.

Filter before you rank

At query time the retrieval service takes the caller's verified identity (from the token, never from a parameter the model or the client supplies), resolves their group memberships, and passes those principals as a filter into the search. The filter must run inside the index, before or during candidate selection. Post-filtering, where you retrieve the top 20 results and then drop the ones the user cannot see, is the most common mistake we find in reviews.

Consider an illustrative example. A procurement analyst asks about the termination clause in a supplier contract. The corpus holds 40 versions of similar contracts, 35 of which belong to other business units. With post-filtering, the vector search returns the 20 nearest chunks, 18 of them from contracts the analyst cannot open; after filtering, two chunks remain, neither from the right contract. The model answers from thin context, or declines, and the team concludes that RAG "does not work on contracts". With the filter applied inside the index, the 20 nearest chunks are drawn only from the five permitted contracts and the right clause is in the set. Post-filtering also leaks in subtler ways: result counts, latency and reranker scores all vary with documents the user should not know exist.

Filtered approximate nearest-neighbour search has its own performance profile. Graph-based indexes such as HNSW degrade when a filter excludes most of the graph, because the search walks through nodes it cannot return. Most engines now handle this with filter-aware traversal or by switching to exact search for very selective filters, but it is worth load-testing with realistic permission distributions, including the user who can see 0.1% of the corpus.

Query-time flow diagram. A user request with a verified token enters the retrieval service, which resolves the user's group memberships. The principal filter is applied inside both the keyword (BM25) search and the vector search over the same index. The two ranked lists are merged with reciprocal rank fusion, passed to a cross-encoder reranker, and the top passages go to the model with their source IDs. The answer returns with citations, and the request, principals, retrieved chunk IDs and answer are written to an audit trace. The permission filter path is highlighted.
Figure 2. The principal filter is applied inside both retrievers, before fusion and reranking, so nothing the user cannot read ever becomes a candidate.

Hybrid search and reranking

Dense embeddings are good at paraphrase and poor at exact tokens. Enterprise questions are full of exact tokens: part numbers, contract references, error codes, people's names, clause 14.2. Keyword search with BM25 handles those well and handles "how do we treat early termination" badly. We run both against the same filtered index and merge the result lists.

The merge we default to is reciprocal rank fusion, introduced by Cormack, Clarke and Büttcher (SIGIR 2009). It ignores raw scores, which are not comparable between BM25 and cosine similarity, and sums a simple function of each document's rank in each list:

def rrf(result_lists, k=60):
    scores = {}
    for results in result_lists:
        for rank, chunk_id in enumerate(results, start=1):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

principals = resolve_groups(verified_token)          # from the identity provider
bm25 = index.keyword_search(query, filter=principals, top=50)
dense = index.vector_search(embed(query), filter=principals, top=50)
candidates = rrf([bm25, dense])[:50]
passages = reranker.rank(query, candidates)[:8]

A cross-encoder reranker over the fused top 50 then does the expensive relevance judgement on a small set. Anthropic's write-up on contextual retrieval reports, on its own benchmarks, that combining contextualised embeddings with BM25 cut failed retrievals by 49%, and adding reranking took that to 67%. Those are vendor-reported numbers on vendor-chosen data, but the direction matches our experience: hybrid plus rerank is the baseline, and pure vector search needs a reason.

Keep the final context small and ordered. Research on long contexts, such as "Lost in the Middle" (Liu et al.), found that models use information at the start and end of the context more reliably than information in the middle. Eight good passages beat thirty mediocre ones, and the reranker's top result should sit where the model will actually read it.

Citations a reviewer can check

A permission-aware system has to show its working, because the user cannot tell from the answer alone whether it came from a document they are entitled to. We require every factual sentence in an answer to cite a chunk ID from the retrieved set, and we render citations as links to the source document at its source URL, opened through the source system's own access control. If the link fails for the user, something upstream is wrong, and that is a useful alarm.

Three implementation details matter. Citations must be validated after generation: the service checks that each cited ID was in the passages actually sent to the model and drops or flags any that were not, which catches fabricated references. The citation should point to a span (page, heading or paragraph anchor), not just a document, so a reviewer can find the sentence in under a minute. And the answer should say so when the retrieved passages do not support an answer, rather than filling the gap from the model's general knowledge; we treat "I could not find this in documents you have access to" as a correct outcome, not a failure.

Freshness and permission drift

An index is a copy, and copies go stale in two ways. Content changes: a policy is revised, a price list is replaced, a contract is amended. Permissions change: someone leaves the deal team, a folder is locked down after a legal hold, a document shared too widely is pulled back. Content staleness produces wrong answers. Permission staleness produces leaks, so it needs a tighter service level.

We set two explicit targets per source and monitor both. A typical starting point is that revocations and deletes propagate within minutes and content edits within an hour; the right numbers depend on the source's change feed and the sensitivity of the corpus. Where a source offers change notifications or delta queries we use them; where it does not, we poll ACLs more often than content, because an ACL read is cheap compared with re-parsing and re-embedding a document. For the most sensitive collections, such as HR or legal, we add a live check: after reranking, the eight final passages are confirmed against the source before they are sent to the model. Eight calls are affordable where fifty are not.

Freshness also shows up in the answer. Each passage carries its last-modified date, and the prompt instructs the model to prefer the most recent authoritative version and to mention dates when sources disagree. Superseded documents are better excluded at ingestion through a status field than left for the model to arbitrate.

Evaluating RAG, including the leaks

RAG fails in two places, and they need separate measurements. Retrieval can miss the right passage, and generation can misuse a passage it was given. Frameworks such as Ragas formalise metrics for both, including context precision and recall for retrieval and faithfulness for generation. We run these through our Evals accelerator against a golden set built from real questions, each labelled with the chunk IDs that should be retrieved and the facts the answer must contain.

Permission-aware RAG adds a third family that most evaluation suites lack: access tests. For each golden question we create synthetic personas with different entitlements and assert what each one must and must not see. These are deterministic checks, not model-graded ones, and a single failure blocks release.

CheckWhat it measuresHow it is scoredGate
Retrieval recall at kWas the labelled passage in the top k after reranking?Deterministic, against labelled chunk IDsRegression threshold
FaithfulnessIs every claim in the answer supported by a cited passage?Model-graded, calibrated against human labelsRegression threshold
Citation validityDo cited IDs exist in the context sent to the model?DeterministicZero tolerance
Access isolationDoes persona B ever retrieve or cite persona A's restricted chunk?Deterministic, per personaZero tolerance
Revocation latencyTime from ACL change at source to exclusion from resultsSynthetic canary document, measured continuouslyAlert on breach of target

The revocation canary is worth describing. A scheduled job creates a document in each source containing a unique token, shares it with a test persona, confirms the persona can retrieve it, then revokes access and measures how long the token keeps appearing. It turns "permissions are synced" from a belief into a number on a dashboard.

Evaluation diagram with three lanes feeding a release gate. Lane one, retrieval quality: golden questions with labelled chunk IDs produce recall at k. Lane two, answer quality: generated answers are scored for faithfulness by a calibrated model grader and for citation validity by a deterministic check. Lane three, access isolation: synthetic personas with different entitlements run the same questions and a revocation canary measures time to exclusion. All three feed a release gate; access isolation and citation validity are zero-tolerance and shown highlighted.
Figure 3. Retrieval, answer and access checks are scored separately, and the access lane can block a release on a single failure.

Where it breaks

Permissions that live in application logic are the first trouble spot. An ERP may let a user see orders only for their own customers, enforced by code rather than by an ACL on a record. There is nothing to ingest. For these sources we either index a projection that has the rule applied per access group, or we keep the data out of the vector index and let an agent query the system's own API under the user's identity, with AgentGate enforcing the scope. The second option is usually better for structured data anyway.

Derived content is the second. If a nightly job summarises a restricted channel into a digest, or a model generates an FAQ from HR cases, the derived document needs the most restrictive ACL of everything it was built from. Few pipelines track that lineage, and it is how restricted facts escape into "general" collections. Embedding inversion is a related concern OWASP raises: vectors can leak information about their source text, so the vector store itself needs the same protection as the documents.

Group explosion is the third. Users in large organisations can belong to hundreds of groups, and some identity providers truncate group claims in tokens past a limit. Resolve memberships server-side from the directory and cache them briefly; never trust a truncated claim to be complete. And accept the residual lag honestly. Unless you check every passage live, there is a window between a revocation and its effect. Size that window, write it down, get the data owner to accept it, and use live checks where they will not.

Finally, we would not start with permission-aware RAG for everything. For a small, uniformly accessible corpus such as public product documentation, the machinery is overhead. The design pays off where access is uneven, which in most enterprises is everywhere that matters.

Before you ship

A short checklist we run before a permission-aware RAG service goes live:

  1. Every chunk carries allowed principals, source ID, ACL version and ACL read time; unparseable ACLs fail closed.
  2. The caller's identity comes from a verified token and group memberships are resolved server-side.
  3. The principal filter runs inside both keyword and vector retrieval, before fusion and reranking.
  4. Citations are validated against the context actually sent to the model and open through the source system.
  5. Revocation and content freshness targets are written down per source, and a canary measures them continuously.
  6. The evaluation suite includes per-persona access tests with zero tolerance, run on every change.
  7. Derived documents inherit the most restrictive ACL of their inputs.

If any item is missing, the assistant is still useful. It is just not yet safe to point at anything you would mind an employee reading.

Have something to build? Let’s talk.

Start a build