Permissioned Retrieval Systems
ACL-aware RAG architecture: query-time permission filtering, IdP groups, no existence leak, audit, failure modes, evals, and why index-time ACL is not enough.
- Read time
- 16 min
- Level
- Advanced
- Updated
- 2026-08-25
- Components
- IdP token resolver · Document ACL index · Query-time filter
A permissioned retrieval system resolves the asking user’s live entitlements from an identity provider, applies those ACLs at query time so unauthorized chunks never enter the retriever’s result set or the model context, and is designed so that empty results, rankings, timings, and citations do not reveal that a forbidden document exists. Index-time ACL snapshots go stale; post-generation filtering is a leak — the model already saw the text.
Key takeaways
- 01
Filter at query time against live IdP groups and document ACLs; baking permissions into the index as the only control goes stale the hour access changes.
- 02
Unauthorized text must never reach the embedding search shortlist, the re-ranker, or the prompt — post-filtering after generation is already a confidentiality failure.
- 03
No existence leak: same empty-result behavior, no “you don’t have access to X,” no citation or count that implies a hidden hit, no timing oracle.
- 04
Service-account indexes that retrieve everything and then filter in the app are how agents leak restricted wikis in week two.
- 05
Evaluate with paired users on the same query: authorized hit vs uniform miss, plus citation and log audits for forbidden doc ids.
Why RAG without query-time ACL is a leak
Enterprise corpora are not public web pages. The same query from two employees should return two neighborhoods of the graph or the index. A single service account that can read the whole wiki, ticket system, and shared drive will retrieve the compensation memo, the unreleased filing, and the other tenant’s case file, then ask the model to “be careful.” The model is not an access-control layer. Once a chunk is in the prompt, the secret has left the store — it will appear in the answer, the trace, the judge, or the cache.
Permissioned retrieval makes authorization a retriever concern. The generator is only allowed to see passages the user could open in the source application today, with their current groups, not last quarter’s index-time snapshot.
Data flow: identity, filter, retrieve, cite
The request carries a user token (OIDC/SAML session, not an API key shared by the app). A resolver talks to the IdP or a group cache and produces a set of principal ids: user, groups, workspace roles, maybe share-link tokens. Those principals are the filter predicate. Dense and sparse retrieval, and graph traversal if you have it, run with that predicate pushed into the index — ACL fields on each chunk, row-level security, or a posting list of principals. The re-ranker sees only authorized candidates. Citations include URLs the same user can open. Traces store chunk ids under access control; a support engineer with a broader role uses their own identity, not the caller’s, if they need to debug.
Ingest still stores ACL metadata on every chunk: owner, ACL version, principals, link-sharing flags, tenant. When the source system emits a permission change, the ACL index updates without waiting for a full re-embed. Re-embedding on every share-toggle is too slow; permission bits must be independently mutable.
- User token → live principals from IdP (with short TTL cache)
- Push principal predicate into vector and BM25 indexes
- Rank and re-rank only inside the authorized set
- Cite only URLs the user can open; refuse on empty retrieval
- ACL fields update on permission events, not only on re-ingest
No existence leak
Access control that returns “forbidden: Q3-M&A-strategy.pdf” has already confirmed the file exists, its title, and often its path. Search that returns “3 results you cannot open” does the same. A hit count, a facet, a graph neighbor, or a different latency when the unauthorized document is in the corpus are all oracles. The authorized-empty and unauthorized-hidden cases must be indistinguishable to the caller: the same refusal text, the same timing class, no leftover ids in logs the user can see.
Caches are oracles too. Do not key a semantic cache on query text alone; include a principal-set hash so user A’s authorized answer is never served to user B. Prefix caches on the inference server that hold another tenant’s RAG context are a cross-tenant leak. Judges and online evals that log full prompts need the same ACL as the product, or the eval store becomes the well-read shadow copy of the secrets.
IdP integration and ACL modeling
Model principals the way the source apps do: users, groups, nested groups, domain-wide claims, and per-document shares. Flatten nested groups at query time or maintain a materialized principal expansion with a TTL you can defend. Break-glass and admin roles are explicit principals, not “skip the filter.” Tokens expire; a long-lived group cache is how offboarded employees keep retrieving until Monday.
Multi-tenant systems add a tenant id that is not optional and is not taken from the query string. Cross-tenant retrieval is a Sev-1 even if the document ACL later would have denied it. For GraphRAG, filter nodes and edges by evidence-document ACL; a community summary must not contain facts whose only evidence the user cannot read — otherwise global search is a summary-shaped leak.
Failure modes
Index-time ACL only: a user loses a group and still retrieves the doc until re-index. Retrieve-then-filter on a small k: the authorized relevant chunk never entered the top-k. Post-generation filtering: the model paraphrases the secret without quoting it. Shared semantic cache across users. Debug traces in a ticket that include another employee’s chunks. Embedding a document before its ACL is known, defaulting to public. Graph summaries built without permission context. Timing differences on filtered vs unfiltered queries.
The failure users report is “the agent knew something I don’t have in the UI.” Treat that as an incident, not a hallucination ticket. The failure security reports is “the agent told me a file existed.” Same queue.
- Stale index-time permissions after group change or offboarding
- Top-k retrieved without ACL, then filtered to empty
- Model saw the chunk; output filter cannot un-see it
- Caches and traces keyed without principal hashes
- Existence leaks via titles, counts, timings, or graph neighbors
Evals, and when not to use this design
Build paired cases: the same query as user A (should hit document D) and user B (must not). Report authorized hit@k, unauthorized leak rate (any forbidden chunk id in retrieval, prompt, answer, citation, or user-visible log), and existence-oracle tests (can B distinguish D’s presence). Include nested-group, expired-share, and tenant-isolation slices. Run these in CI on retriever changes. Online: sample traces and scan for chunk ids outside the caller’s principal set.
You still need this design if the corpus has any ACL at all. The “when not” is narrower: a truly public knowledge base with one tenant and no employee-only docs can skip principal expansion — and should still isolate caches per environment. Do not “simplify” by retrieving as a superuser for an internal agent that Slack-broadcasts answers. If you cannot connect the IdP, do not index the restricted sources; a smaller permissioned corpus beats a clever leak.