Permission-Aware Retrieval: Carrying Entitlements Through the Whole Pipeline
Enterprise AI pilots stall at the permission model more often than at model quality: the pilot indexes one curated folder, production has to index SharePoint, file shares and SAP. Carrying entitlements through retrieval requires the ACL as a filterable field on every chunk, the user identity reaching the index, and revocation that lands within one sync.
The pilot has no permission model, which is exactly why it works
A pilot that indexes two hundred hand-picked PDFs in one folder has no access-control problem, because everyone allowed near the pilot has the same access. Production does not look like that. The corpus becomes SharePoint, a file share, the ticket system and a handful of SAP tables, and the pipeline that worked on the folder now has to answer a question nobody asked it during the demo: who is allowed to see this chunk?
That question has no default answer, and most pilot architectures cannot express one.
MIT's Project NANDA put a number on the general failure rate in 2025: roughly 95 percent of enterprise generative-AI pilots produced no measurable P&L impact, drawn from 150 interviews, 350 employee surveys and 300 public cases. The report attributes that to approach and to a learning gap, not to access control. Our claim is narrower. Access control is one of the concrete places where approach turns into a blocked deployment, and it blocks late, after the demo has already convinced a sponsor.
Microsoft's own mitigation shows the shape of the problem. Restricted SharePoint Search caps what Copilot can discover to an allow list of at most 100 sites, and the documentation describes it as a short-term measure while administrators review and audit site and file permissions, explicitly not scalable for long-term use. That is a shipped product feature whose job is to buy time for a permissions cleanup nobody finished.
Four ways the permission model breaks, named
The failure modes are not exotic. Four of them account for most blocked deployments, and each one is documented by the vendor rather than passed around as folklore.
There is a fifth, and it survives even a careful review of the retrieval path.
Derived artifacts inherit nothing. Summaries, cluster labels, cached answers, evaluation logs and the traces your observability stack keeps for debugging are all copies of content whose access rules stayed behind at the source, and none of them are covered by the filter that protects the index. A system can enforce entitlements perfectly at query time and still hand a restricted paragraph to anyone with read access to the trace viewer.
- Chunk amnesia. The ACL belongs to the document, the index stores chunks. Azure's documentation states that when a skillset splits documents, permission metadata has to move into index projections, and that without that projection chunk-level references are not filtered at all.
- The confused deputy. Retrieval runs under a service account with broad read rights and the end user's identity never reaches the index. The system answers correctly and leaks completely.
- Token blindness. Microsoft Entra ID stops emitting a groups claim above 200 groups for JWT and 150 for SAML, returning an overage claim that points at Microsoft Graph instead. A filter built straight from the token silently under-returns for exactly the senior people who complain loudest.
- Sync lag. Query-time enforcement compares the caller's claims against permission metadata already written into the index. A revoked right stays effective until the next successful sync, and for SharePoint permissions inherited from a site or library, until someone triggers an explicit resync.
What the filter actually looks like at query time
In the string-filter pattern the entitlement is a field on the chunk and the query carries the caller's group identifiers. Azure AI Search documents the exact shape: a Collection(Edm.String) field, filterable, not retrievable, matched with the search.in function.
Two details decide whether that snippet holds up. The group_ids values have to be written to every chunk row rather than to the parent document, and the identifier list has to be the caller's full group membership, resolved from Microsoft Graph whenever the token carries an overage claim instead of a groups claim.
The function choice matters at scale too: a disjunction of equality expressions over thousands of values costs seconds, while search.in stays subsecond.
Microsoft is blunt about what this pattern is not: "There's no authentication or authorization through the security principal. The principal is just a string, used in a filter expression." Treat it that way. A filter is exactly as trustworthy as the ingestion job that wrote the string, which is why the interesting engineering sits upstream of the query, in the code that resolves identities and projects them onto chunks, rather than in the filter expression everyone reviews.
PUT /indexes/kb-chunks?api-version=2026-04-01
{
"name": "kb-chunks",
"fields": [
{ "name": "chunk_id", "type": "Edm.String", "key": true },
{ "name": "doc_id", "type": "Edm.String", "filterable": true },
{ "name": "content", "type": "Edm.String", "searchable": true },
{ "name": "group_ids", "type": "Collection(Edm.String)",
"filterable": true, "retrievable": false }
]
}
# group_ids is written per chunk by the ingestion job, never per document.
# The caller's group list comes from the token, or from Microsoft Graph
# when the token carries an overage claim instead of a groups claim.
POST /indexes/kb-chunks/docs/search?api-version=2026-04-01
{
"search": "warranty terms for the press line commissioned in 2024",
"filter": "group_ids/any(g: search.in(g, '4f2c...a1, 9b81...07'))",
"top": 8
}SAP has no documents, so the pattern does not survive contact with it
SAP authorisations are value-based, not document-based. Nobody is granted a file. A user is granted display access to company code 1000 and plant 2200 through an authorisation object, and that grant applies to rows in ACDOCA, not to an object carrying an ACL you can lift into a search index.
Copying it anyway is the mistake.
Flattening company codes and plants into pseudo-group strings converts a maintained authorisation concept into a second, unmaintained one, and the two drift apart the moment a role changes in PFCG. ABAP CDS access control exists so you do not have to do that. A DCL role binds the view to the classic PFCG authorisation through the pfcg_auth aspect, and with @AccessControl.authorizationCheck set to #CHECK the restriction applies automatically on Open SQL reads.
Our position: structured entitlements belong at call time, not at index time. Index the vocabulary if you need it, meaning material descriptions, document texts, WBS names, and read the numbers through a view that checks the authorisation on every call. The obvious objection is latency, plus the loss of semantic search across the rows themselves. Both are real. Both are still cheaper than owning a private copy of SAP's authorisation concept inside a vector store, which is a maintenance liability that grows every time Finance reorganises.
@AccessControl.authorizationCheck: #CHECK
define view entity ZI_OpenItem
as select from acdoca
{
key rbukrs as CompanyCode,
key gjahr as FiscalYear,
key belnr as AccountingDocument,
key docln as LineItem,
hsl as AmountInCoCodeCurrency
}
@MappingRole: true
define role ZI_OpenItem_Access {
grant select on ZI_OpenItem
where (CompanyCode) = aspect pfcg_auth(F_BKPF_BUK, BUKRS, ACTVT = '03');
}Where the entitlement can live, and what each option costs you
There are five places to put the access decision, and the choice trades revocation lag against how much of the source system's access model you end up reimplementing.
Row two is the working default for document corpora and row five is the working default for anything transactional. Running both in one system is normal and correct, provided the answer names which path produced each fact.
One procurement note before the architecture debate starts: document-level and field-level security in Elasticsearch sit behind the paid subscription tiers rather than the free distribution, so the sentence "we will just filter in the index" can turn into a licence negotiation instead of a configuration change. Settle that before the option lands in a slide, because it otherwise reappears three weeks later as a surprise line item.
A second note in the same category, and it is the one procurement asks first: Azure AI Search's native query-time enforcement — POSIX-style ACLs, RBAC scopes, SharePoint ACL ingestion and Purview sensitivity labels — is part of the 2026-05-01-preview REST API. Preview means Azure Preview terms, and those exclude production workloads. Row two, by contrast, is generally available and is a plain field filter that works the same way on any search engine, which is the second reason it is the default rather than the fallback.
What is not defensible is choosing a row implicitly, by whichever SDK sample the first engineer happened to copy.
| Where the entitlement lives | What decides access | Revocation lag | Breaks when |
|---|---|---|---|
| One index per audience | Which index the application queries | Rebuild of that index | Audiences overlap, or a document belongs to two of them |
| Security filter field on the chunk | String match between caller group IDs and chunk group IDs | Next indexer run or push update | Chunks lose the field, or the token omits the groups claim |
| Native token enforcement (ACLs, RBAC scopes) — preview | Entra claims on the query token against permission metadata in the index | Next sync; inherited SharePoint changes need an explicit resync | The source has no ACL the connector can read |
| Trim after retrieval, in the application | A per-hit call back to the source system | None | Latency and API cost; the ranker already saw what the user may not |
| Call the source system at query time | The source system's own check, for example an SAP CDS role | None | No semantic search over that data; you need a query it understands |
The strongest argument against doing any of this
Skip the entitlement model. Build the assistant on a corpus that is deliberately readable by everyone in the company: policies, handbooks, product documentation, the public half of the wiki. No filters, no sync lag, no identity plumbing, and it ships in weeks instead of quarters.
The argument is good, and it wins more often than architects like to admit. If the questions people actually ask are about travel policy, machine manuals and onboarding, an entitlement model is overhead you never needed.
It stops winning at the first question that touches a person, a price or a project. Salary bands, supplier terms, disciplinary records, unreleased engineering changes, customer-level margin. Those happen to be the questions where an answer is worth real money, which is why the curated-corpus assistant tends to plateau at pleasant and unimportant, and why its second year is much harder to fund than its first.
Choose one deliberately, then say which one you chose. Users will assume the other.
The order this gets built in
Identity first, index schema second, content last. Reversing that order is what turns entitlement work into a rewrite, because both the chunk schema and the ingestion path depend on decisions only the identity model can make.
The negative set is the step that gets dropped, and it is the one a security review actually asks about. Relevance evaluations prove the system finds things; only a negative set proves it stops. Build it from real people and real restricted records rather than synthetic examples, because the failures worth catching come from inheritance quirks nobody would think to invent.
None of this needs a new platform. It needs one owner, one representation, one measured lag.
- Name the source of truth for entitlements per system, and write down who maintains it. Where two systems disagree about a group, decide now which one wins.
- Fix the identity flow end to end before indexing anything. The caller's identity has to arrive at retrieval as a token, not as a username in a JSON body. RFC 8693 already defines the delegation semantics, including the act claim for a service acting on behalf of a user.
- Make the entitlement a first-class field in the chunk schema, populated by the ingestion job rather than backfilled once someone notices.
- Measure the revocation path. Remove a user from a group, then time how long the old answer keeps coming back. That number is a service level and it belongs in the documentation.
- Build a negative evaluation set: queries paired with a user who must receive nothing, run in CI next to the relevance evaluations.
Questions we get asked about permission-aware retrieval
What is permission-aware retrieval?
Permission-aware retrieval means every chunk in the index carries the access rules of the record it came from, and every query is filtered against the identity of the person asking. The check runs inside retrieval, before ranking. It is not a prompt instruction and not a cleanup pass on the answer. Without it, one index row is readable by anyone who can reach the search endpoint.
Can we filter the results after the model has written its answer?
No. By that point the content has been in the context window, in the provider request log and probably in a cache. Post-hoc filtering also wrecks ranking, because the retriever spent its top slots on documents the user cannot see and the answer gets built from whatever was left over. Filter inside retrieval, then rank what survives.
How long does a revoked permission stay effective in the index?
Until the permission metadata is synchronised. Microsoft documents this for Azure AI Search: changes in the source system only affect results after a subsequent indexer run, a push-API update or a Purview-driven refresh. For SharePoint, items with unique permissions refresh on each successful indexer run, while inherited changes need an explicit resync. Decide the acceptable lag first, then pick the mechanism.
What does it cost to retrofit entitlements into an existing RAG system?
The two-week blueprint is fixed at €4,900 net and ends with the index schema, the named source of truth for entitlements, and a revocation path with a measured lag. Build work runs from €18,500 per month. The driver is the number of source systems and whether an identity model already exists, not the size of the corpus.
When does permission-aware retrieval not work?
When the source carries no machine-readable access rule: scanned archives on a network share, shared mailboxes, folders whose inheritance nobody can reconstruct. It also breaks when rights are per field rather than per record, and when no one in the organisation will decide who may see what. We can carry an access model through a pipeline. We cannot invent one that was never agreed.
Bring the access model to the architecture call
Two weeks, fixed price. We map the entitlement sources across your systems, define the chunk schema that carries them, and measure the revocation lag you would actually get in production. The plan is yours to keep whether or not we build it.