Area 06 // AI SecurityLesson 100 / 14014 min read
Access control in RAG
Access control in RAG makes sure a user can only retrieve, and so only get answers from, documents they are allowed to see, by storing each chunk's permissions with it, filtering retrieval by the user's identity before ranking, and keeping those permissions in sync with the source systems.
Think of it like this
Analogy
A library with a restricted section. Every restricted book has a coloured sticker saying who may borrow it. When you ask the librarian for help, they only search shelves your card allows, rather than fetching all the best books and then hiding some behind their back as they walk over. Searching only the allowed shelves means you also get the best of what you can read.
The problem it solves
Company knowledge bases mix public, team-only and confidential documents: HR cases, salaries, merger plans, customer contracts. Put them all in one vector index and ask a question, and the retriever returns the most similar chunks, regardless of who's asking. The model then cheerfully summarises the CEO's memo for an intern. OWASP lists this under sensitive information disclosure and vector and embedding weaknesses.
How it works, step by step
- Capture permissions at ingestion: for each document, read its ACL from the source (SharePoint, Google Drive, Confluence, ticketing system) and store it on every chunk as metadata: allowed user IDs, group IDs, tenant ID, sensitivity label (ingestion).
- Resolve the user at query time: from authentication, look up their groups (cache briefly); never take identity or groups from the request body or the model.
- Filter during retrieval: pass a metadata filter (
acl ∩ user_groups ≠ ∅andtenant = user_tenant) to the vector database so it searches only allowed chunks (vector databases support filtered search; pgvector uses aWHEREclause). - Apply the same filter to every retriever: keyword search, hybrid search, graph lookups, caches (hybrid search).
- Rank, rerank and answer only from allowed chunks; citations link to documents the user can open.
- Keep ACLs in sync: subscribe to permission-change events or re-crawl often; delete chunks of deleted documents promptly.
- For strong isolation, use separate indexes or namespaces per tenant (multi-tenancy).
- Test it: eval cases asserting that user X cannot retrieve document Y; audit retrieval logs (audit logging).
See it in code
- Needs
- Nothing: plain Python 3. Scores stand in for vector similarity.
Filtering before ranking vs ranking then filtering, for a sales rep and an executive.
# Access control in RAG: every chunk carries an ACL copied from its source; filter BEFORE ranking.
CHUNKS = [
{"text": "Q3 layoffs plan: reduce sales team by 12%", "acl": {"hr", "execs"}, "score": 0.91},
{"text": "Merger with Acme closes in December", "acl": {"execs"}, "score": 0.88},
{"text": "Holiday policy: 25 days plus bank holidays", "acl": {"everyone"}, "score": 0.74},
{"text": "Sales team targets for Q3", "acl": {"sales", "execs"}, "score": 0.70},
{"text": "How to book leave in the HR portal", "acl": {"everyone"}, "score": 0.65},
{"text": "Expense policy for client dinners", "acl": {"everyone"}, "score": 0.52},
]
def search(user_groups: set, k: int = 3, filter_first: bool = True):
groups = user_groups | {"everyone"}
if filter_first: # correct: the index only returns allowed chunks
allowed = [c for c in CHUNKS if c["acl"] & groups]
return [c["text"] for c in sorted(allowed, key=lambda c: -c["score"])[:k]]
top = sorted(CHUNKS, key=lambda c: -c["score"])[:k] # wrong: rank everything, filter afterwards
return [c["text"] for c in top if c["acl"] & groups]
sales_rep = {"sales"}
print("filter first :", search(sales_rep))
print("filter after :", search(sales_rep, filter_first=False), "<- only 1 result left")
print("exec :", search({"execs"}))filter first : ['Holiday policy: 25 days plus bank holidays', 'Sales team targets for Q3', 'How to book leave in the HR portal']
filter after : ['Holiday policy: 25 days plus bank holidays'] <- only 1 result left
exec : ['Q3 layoffs plan: reduce sales team by 12%', 'Merger with Acme closes in December', 'Holiday policy: 25 days plus bank holidays']Post-filtering didn't leak here, but it threw away the sales targets the rep was allowed to see; and a bug in the "after" step is a direct leak. In pgvector the filter is a WHERE acl && :groups clause next to the ORDER BY embedding <=> :query.
A real example
Example
The intern who found the salaries
A company launched an internal assistant over its shared drives. Within a week, an intern asked "what do engineers earn here?" and received a summary of a salary spreadsheet. The spreadsheet sat in an HR folder, but ingestion ran under a service account that could read everything, and no ACLs were stored.
The rebuild mirrored each file's sharing settings onto its chunks, filtered every query by the user's groups from the identity provider, excluded files labelled "Confidential" entirely, synced permission changes every 10 minutes, and added 50 eval cases of the form "user from team X asks about restricted document Y → must not be retrieved".
The easy way
Start here
Store a tenant_id and a list of allowed groups on every chunk, and add a filter on both to every search query using the signed-in user's groups. Start by indexing only documents everyone may see, then add restricted ones once filtering is tested.
When you grow: the production setup
Enterprise RAG mirrors document-level ACLs from source systems through connectors with change events, resolves users' groups from the identity provider, enforces filters inside the vector and keyword stores (or per-tenant indexes), applies sensitivity labels, shortens sync lag with SLOs, audits retrieval, and runs permission evals in CI (design a document Q&A system).
Trade-offs
What you gain
- Users get answers only from what they're allowed to see.
- One index can safely serve many teams.
- Citations remain openable by the user.
What it costs you
- Connectors must read and sync permissions, which is hard.
- Filtered vector search can be slower or less accurate on highly selective filters.
- Permission changes create brief leak windows until synced.
When not to use it
- If every document is public to every user, you need tenant isolation but not per-document ACLs.
Common mistakes
- Ingesting with an all-access service account and storing no ACLs.
- Filtering the vector search but not the keyword search.
- Post-filtering the top-k results.
- Taking the user's groups from the request instead of authentication.
- Never removing chunks of deleted or re-permissioned documents.
Interview questions
Try answering out loud first, then open the model answer.
How do you enforce permissions in a RAG system?
Model answer
Copy document ACLs onto chunks as metadata at ingestion; at query time resolve the authenticated user's groups and tenant; apply a pre-ranking filter in every retriever (vector, keyword, graph, caches); rerank and answer only from allowed chunks; keep ACLs synced with source systems and delete removed documents; use per-tenant indexes for strong isolation; and test with permission evals and audit logs.
Why is post-filtering retrieved results a bad idea?
Model answer
Retrieving the global top-k and then dropping forbidden chunks can leave the user with few or no results even though allowed relevant chunks exist, hurting quality, and any bug in the post-filter step becomes a direct leak. Pre-filtering in the search engine both protects data and returns the best allowed results.
Can't the system prompt tell the model not to reveal restricted documents?
Model answer
No. If restricted text is in the context, prompt injection or simple model error can reveal it, and the model can't verify the user's permissions anyway. Access control must keep restricted text out of the context entirely.
Quiz
Pick an answer to see whether it’s right and why.
0 of 5 answered
Key terms
- RAG access control
- Filtering retrieval by the asking user's permissions (using access lists stored with each chunk), so restricted documents never reach the model or the answer.
- ACL (access control list)
- A list attached to a resource saying which users or groups may access it. In RAG, each chunk carries its document's ACL so retrieval can filter by it.
- Metadata filter
- A condition on stored fields (tenant, ACL, date, source) applied during a vector or keyword search so only matching chunks are considered.
- Tenant isolation
- Keeping each customer's data, caches, memory and limits separate so one tenant can never see or affect another's.
- Retrieval
- Finding the pieces of stored information most relevant to a question, for example by keyword or vector search.
Before and after
Progress is saved in this browser only.