Skip to content
allsrc.dev
Go back

Your RAG Pipeline Does Not Know Who Is Asking

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

When NOT to use it

Trade-offs and failure modes

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


Part of the agent harness and governance series. Next: memory isolation — the injection that does not attack today’s session, but tomorrow’s.



Previous Post
One Redaction Function Is Always Wrong
Next Post
The Prompt Injection That Waits Until Tomorrow