A policy chatbot indexes the company document store. A contractor asks: “what severance and notice period applies to VP-level termination?”
The Executive Severance Schedule is in the index. It is the single most relevant document. It is HR-confidential.
The bot answers beautifully.
Nobody decided this. The vector store had no idea who was asking, and the only thing between the contractor and board-approved severance terms was whether the model felt like mentioning them.
TL;DR
Two failures live here, and the second one appears after you fix the first.
Post-filtering leaks. Retrieve the top-k, then drop what the user cannot see. This feels equivalent to filtering first and is not. The obvious cost is answer quality: a permitted-but-lower-ranked document never surfaces. The real problem is structural — “filter afterwards” tends to live in the same layer that assembles the prompt, and one refactor later the unfiltered chunks are in the context window. Authorisation has to be a property of the query, not a step after it.
Unfalsifiable answers. Fix retrieval and the model still produces a confident answer citing the confidential document, because that is what the question was about. An answer you cannot trace is an answer you cannot trust, cannot defend, and cannot retract when a source is withdrawn.
So: authorise inside the query, return citation-tagged chunks, and verify after generation that every cited document was actually retrieved for this principal.
OWASP: ASI06, ASI03.
Runnable code:
patterns/governance/rag_access_control/
Both failures, as program output
uv run python -m patterns.governance.rag_access_control.demo
=== WITHOUT the pattern: retrieve first, hope later ===
retrieved for a CONTRACTOR:
[doc:hr-101] (source: Employee Handbook 2026, classification: public)
[doc:hr-874] (source: Executive Severance Schedule, classification: confidential)
[doc:hr-455] (source: Contractor Terms, classification: internal)
answer: VP-level severance is 12 months of base salary plus accelerated
vesting [doc:hr-874] ...
=== WITH the pattern: authorization is part of the query ===
retrieved for a CONTRACTOR:
[doc:hr-101] (source: Employee Handbook 2026, classification: public)
[doc:hr-455] (source: Contractor Terms, classification: internal)
answer: I could not produce a verifiable answer: the draft cited sources
(hr-874) that were not retrieved for you. Nothing has been shared.
violations: [('fabricated_citation', "answer cites ['hr-874'], ...")]
The second block is the interesting one. Retrieval correctly returned only what
the contractor may see — and the model cited hr-874 anyway. Provenance
checking caught it and withheld the whole answer.
The pattern
# Authorisation inside the query, evaluated before ranking:
documents = corpus.search(query, principal.id, frozenset(principal.roles))
return render(documents) # every chunk tagged [doc:hr-101]
guard = RagAccessControl() # after_tool: record what was authorised
harness = Harness(model, tools, hooks=[guard]) # after_model: verify citations
1. Pre-filter, never post-filter. In production this is a metadata pre-filter
pushed into the engine, or a per-tenant index — not a list comprehension after
.similarity_search().
2. ACLs live on the document, beside the content they protect. Not in a parallel permissions table that drifts, and not in the prompt.
3. The harness verifies provenance, not the model. Every cited id must be in the set retrieved for this principal. A citation that is not gets the whole answer withheld — fail closed on the output, not just the input.
4. This checks provenance, not truth. It catches a model citing a document it was never given: common, cheap to detect, and otherwise indistinguishable from a correct answer. It does not verify that the cited document supports the claim. I want to be explicit about that limit, because it would be easy to oversell — groundedness is what evaluations are for.
5. Show sources to the user. provenance_footer() renders citations into a
visible source list, which makes the whole thing auditable by the person best
placed to notice a problem.
When to use it
- Any RAG system where different users may see different documents — HR, legal, finance, customer data, multi-tenant SaaS.
- Any regulated domain where “which source supports this claim?” is a question you will be asked, or where a source must be retractable.
- Multi-tenant products, always. Tenant isolation is the one case where a bug is a headline.
When NOT to use it
- Uniformly-readable corpora. Public documentation, open-source code, a handbook every employee already has. Per-principal filtering there is overhead that tempts you to build an entitlement model you do not need.
- Citation enforcement on conversational turns. Forcing citations on “hi, can you help?” produces exactly the refusals users hate. The guard deliberately skips answers where no retrieval ran — keep that exemption.
- As a substitute for indexing decisions. If confidential documents should not be reachable by this agent at all, do not index them and then filter. Separate indexes beat clever filters. The filter is your second line.
- Analytical agents where synthesis is the point and citation-per-sentence destroys the output. Keep the ACL half, drop the citation requirement, and say so rather than quietly disabling it.
Trade-offs and failure modes
- Chunk-level ACLs are the hard part. A document is one ACL; its chunks are many rows in a vector index, and they drift the moment permissions change. Re-indexing on permission change is expensive; not doing it is a leak with a delay. Most teams under-budget this by a wide margin.
- Embeddings leak, quietly. Even with perfect query-time filtering, embeddings computed over confidential text can leak content through similarity, and a shared index is a shared inference surface. Separate indexes per classification level when the data warrants it.
- The empty-result answer is itself a signal. “No documents you have access to match this query” tells the contractor a document probably exists. In sensitive contexts, respond identically to “nothing found” and “nothing permitted.”
- Citation syntax is model-fragile. Models reformat
[doc:hr-101]into prose, footnotes, or markdown links. Keep the syntax boring, state it in the retrieval result, and treat parse failures as violations rather than passes. - Withholding correct answers is a real cost. You will block good answers that cited sloppily. Log every violation and review them — a rising uncited-answer rate usually means your prompt, not your users, needs fixing.
Frequently asked questions
Can I do this with my existing vector database?
Yes, if it supports metadata pre-filtering (Pinecone, Qdrant, Weaviate, pgvector with a WHERE clause, Azure AI Search with filters). The requirement is that the filter is pushed into the search call so it constrains ranking, not applied to the results afterwards.
Namespaces or filters for multi-tenancy?
Namespaces — a separate index or namespace per tenant. A filter is one forgotten clause away from a cross-tenant leak; a namespace makes the leak unrepresentable. That is the same argument as the key derivation in memory isolation.
How do I handle documents whose ACL changes?
Treat permission changes as re-index events, and accept that there is a window. Measure the window, state it in your security documentation, and make it shorter for your most sensitive classification rather than uniformly.
Does citation verification stop hallucination?
No. It stops one specific, common, cheap-to-detect kind of hallucination: citing a source that was never provided. A model that misreads a document it did receive will pass this check. Do not let the green light mean more than it does.
References
- OWASP GenAI Security Project, Top 10 for Agentic Applications 2026 — ASI06, ASI03
- OpenAI, Agentic Governance Cookbook — output-stage guardrails, eval-driven threshold tuning
- Anthropic context-engineering guidance — what enters the window is a design decision
- Runnable code and tests:
patterns/governance/rag_access_control/
Part of the agent harness and governance series. Next: memory isolation — the injection that does not attack today’s session, but tomorrow’s.