Guide · AI and GenAI
RAG Access Control: Permission-Aware Retrieval Explained
24 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- RAG access control means one rule: never retrieve text the asking user could not open in the original system. RAG is when your program searches your documents and pastes the best matches into the prompt, the text sent to the AI model.
- Each document has an access list: who may open it. Copy that list onto the document's chunks, the small pieces you search, as group ids (named teams in your company directory). Then filter every search by the user's groups, before the model sees any text.
- Keep those copied lists in sync with the original systems, applying removals first. When a leak would be costly, ask the original system again about the final few results, and deny if it does not answer.
- A line in the prompt asking the model to hide salaries is not a security control. OWASP, a non-profit security group, says the same.
- Beginners can stop after the section on prompt rules. Engineers get runnable Python after it: short filtered results, cache keys (labels for stored answers) scoped by permission, a live check that denies when unsure, and a fake-file leak test.
What goes wrong when a company AI assistant can search every document?
It answers from text the asking person was never allowed to open, and nothing in the chat looks wrong. The search step can see more documents than the person asking. Closing that gap is what RAG access control is about.
Picture a common setup. A steel company with about 6,000 staff gave them an internal assistant that answers questions from company documents. The setup is made up, but the problem is real. The internal tools team pointed it at the shared drive, the HR system and the legal folder, about 1.8 lakh (180,000) documents in all.
Staff liked it. Plant engineers asked about blast furnace safety, and new joiners asked how to apply for leave. Then, one Thursday evening, a shift supervisor in the cold rolling mill asked for the pay band of a senior engineer.
The assistant did not give a band. It quoted rows from an HR spreadsheet called Salary Revision 2026, with three engineers' names and monthly pay such as Rs 1,45,000 (145,000 rupees). The supervisor reported it to the security lead within the hour.
The team traced the problem in one afternoon. The indexer is the program that reads documents into the search index, a store built ahead of time for fast lookups. It ran with an admin account that could read every folder. It copied each file's text, but not the list of who may open it.
Follow the arrows below from the indexer to the leak. No box ever asks who the user is, and that missing question is the whole bug.
How the salary sheet leaked
Drag to exploreEvery step worked as built. Nobody built a step that checks permissions.
Diagram connections:
- Indexer reads every folder to One shared search index
- One shared search index to Search ranks by meaning only (All chunks are candidates)
- Supervisor asks about pay to Search ranks by meaning only
- Search ranks by meaning only to Salary sheet chunk wins
- Salary sheet chunk wins to Model writes the answer
- Model writes the answer to Salary shown to wrong person
- Indexer reads every folder: Runs with an admin account
- One shared search index: Text copied, permissions dropped
- Supervisor asks about pay: Plant staff, not part of HR
- Search ranks by meaning only: Never asks who is asking
- Salary sheet chunk wins: Closest match to the question
- Model writes the answer: Quotes names and monthly pay
- Salary shown to wrong person: Reported to the security lead
Which words do you need before permission-aware RAG?
You need six words. Each gets a plain meaning and an example from the steel company.
RAG. RAG, short for retrieval-augmented generation, means your program first searches your own documents, then puts the best matches into the prompt, the text sent to the model. Example: the assistant finds the leave policy, and the model explains it.
Chunk. A chunk is a small piece of a document, often a few paragraphs, that is stored and searched on its own. Example: the 40-page blast furnace safety manual becomes about 120 chunks.
Vector search. Vector search finds chunks whose meaning is close to the question, even when the words differ. Each chunk becomes a list of numbers called an embedding, and similar meanings get similar numbers. Example: the words pay band match a file about salary revision.
Access control list. An access control list, or ACL, is the list of people and groups allowed to open a file. Example: the Salary Revision 2026 sheet is open only to the HR Compensation group and the finance head.
Group. A group is a named set of people, such as Plant 2 Supervisors. Groups live in the company directory, the system that knows every employee and their groups.
Metadata filter. Metadata is extra data stored next to each chunk, such as its ACL. A metadata filter tells the search to consider only chunks whose metadata passes a rule, such as sharing one of your groups.
What is RAG access control?
RAG access control is the rule that a RAG system only retrieves text the asking user could open in the original system. If the supervisor cannot open a file in the HR system, no chunk of that file should ever reach his prompt.
The source system stays the boss. The HR system and the shared drive already decide who can open what. Your RAG system copies those rules and obeys them, at or before retrieval, in code you control, never inside the model.
Picture the tool store at a plant. The storekeeper is your search step, the worker's ID card is the signed-in identity, and the certificate list is the ACL. A welding set leaves the counter only when the card matches the list.
Where the picture breaks: the storekeeper reads the current list, but your index holds a copy of permissions taken at indexing time. Copies go stale, which is why later sections add syncing and a live check.
OWASP is a non-profit group that publishes security guidance. Its 2025 list of the top 10 risks for apps built on large language models includes vector and embedding weaknesses. That entry recommends permission-aware vector stores and strict logical and access partitioning of data.
Here is the fixed flow for one question. Watch where the user's groups enter the search, and where a chunk can still be dropped.
One permission-aware question
Drag to exploreThe user's groups shape the search before the model sees a single chunk. The live check can still drop a chunk whose copied permissions are out of date.
Diagram connections:
- User asks a question to Confirm who is asking
- Confirm who is asking to Fetch the user's groups
- Fetch the user's groups to Search with group filter
- Search with group filter to Still allowed to open it? (Top 5 chunks)
- Still allowed to open it? to Build prompt and answer (Yes)
- Still allowed to open it? to Drop the chunk and log it (No)
- Build prompt and answer to Send the answer
- User asks a question: Supervisor → Assistant
- Confirm who is asking: Assistant → Sign-in service
- Fetch the user's groups: Assistant → Company directory
- Search with group filter: Assistant → Vector index
- Still allowed to open it?: Assistant → Source system
- Drop the chunk and log it: Assistant → Audit log
- Build prompt and answer: Assistant → Model
- Send the answer: Assistant → Supervisor
For the supervisor's pay question, the salary sheet is never even a candidate. The search returns the public pay policy instead, which tells staff to ask HR. The live check in the fifth box is a second lock, covered later.
Why is a prompt rule not enough to protect documents in RAG?
A prompt rule is not enough because it only asks the model to behave, and the model can fail or be tricked. Once restricted text is inside the prompt, assume the user can get it out.
The team's first fix was a line in the system prompt, the fixed instruction text sent before every question. It asked the model never to reveal salary details outside HR. It passed their first ten tests.
The security lead broke it in five minutes. She asked for the salary table as a poem, then for the poem in Hindi. A model follows instructions most of the time, not every time.
OWASP agrees. Its entry on system prompt leakage says the system prompt should not be used as a security control. Authorization checks must not be handed to the model; they belong outside it, done in a deterministic, auditable way.
Deterministic means the same input always gives the same result. Code that compares a user's groups with a chunk's ACL is deterministic. A model reading an instruction is not.
Filtering the answer afterwards has a similar weakness. A filter that looks for rupee amounts can miss a salary written out in words. A 2025 position paper on enterprise assistants calls defences such as output filtering fundamentally probabilistic, and argues for deterministic access control instead.
Should you filter at query time or keep separate indexes?
Most teams filter at query time inside one shared index, and add separate indexes only for data with a clean, hard boundary. The right choice depends on how your permissions are shaped. This is where the engineer part of the guide starts.
How does a query-time filter work?
With a query-time filter, every chunk carries its ACL as metadata. The search only considers chunks whose ACL shares an entry with the user's groups or user id. Every result is then a chunk the user could open when its ACL was last copied.
Avoid the opposite order, called post-filtering, where your own code filters after the search. The program searches everything, takes the top 5, then drops chunks the user may not see. The supervisor may get one weak result, or none. If the dropping happens after the model reads them, it is too late.
A 2026 workshop paper calls the safe order authorization-first retrieval: permissions must narrow the candidates before any model reads retrieved text. In that paper's own test setup, pipelines that retrieved first and filtered later let unauthorized text reach the model in 86.1% of queries.
Here is a query-time filter you can run with plain Python 3. The tiny index and the word-matching search are stand-ins for a real vector database, so you can see the access logic alone. A principal is anyone an ACL can name: a person or a group.
CHUNKS = [ # a tiny index: each chunk carries its document's ACL
{"id": "pay-policy#1", "doc_id": "pay-policy", "acl": {"all-staff"},
"text": "pay bands are reviewed each april ask hr for your band"},
{"id": "salary-2026#3", "doc_id": "salary-2026", "acl": {"hr-comp"},
"text": "senior engineer pay band revised monthly pay"},
{"id": "legal-memo#2", "doc_id": "legal-case-7", "acl": {"hr-comp"},
"text": "court notice about an engineer pay dispute"},
{"id": "safety#12", "doc_id": "safety-manual", "acl": {"all-staff"},
"text": "blast furnace safety checks before each shift"},
]
DIRECTORY = {"u-supervisor": ["all-staff", "plant2-supervisors"],
"u-hr": ["all-staff", "hr-comp"]}
def get_user_groups(user_id):
return DIRECTORY.get(user_id, [])
def vector_search(question, top_k, acl_filter):
# stand-in for a real vector search: rank by shared words
allowed, words = set(acl_filter), set(question.lower().split())
hits = [c for c in CHUNKS if c["acl"] & allowed] # filter first
hits.sort(key=lambda c: -len(words & set(c["text"].split())))
return hits[:top_k]
def search_for_user(user_id, question, top_k=5):
if not user_id:
return [] # no identity: deny
principals = set(get_user_groups(user_id)) | {"user:" + user_id}
return vector_search(question, top_k, acl_filter=principals)
q = "what is the pay band for a senior engineer"
print([c["id"] for c in search_for_user("u-supervisor", q)])
print([c["id"] for c in search_for_user("u-hr", q)])
print(search_for_user("", q))Run it and you should see three lines. The supervisor gets the pay policy and a safety chunk, never the salary sheet. The HR user gets the salary sheet first. Nobody signed in gets an empty list.
['pay-policy#1', 'safety#12']
['salary-2026#3', 'pay-policy#1', 'legal-memo#2', 'safety#12']
[]Two choices in this code matter. With nobody signed in, it returns nothing: no identity means no documents, a rule called deny by default. And user_id comes from the sign-in session, never from the question text, so typing someone else's name changes nothing.
Why can a filtered search return fewer results than you asked for?
A filtered search can come back short when the filter is applied after an approximate index is scanned. An approximate index, such as HNSW (hierarchical navigable small world, a graph of nearby chunks), checks only part of the data, trading a little accuracy for speed.
As of 19 October 2026, the pgvector README, for the PostgreSQL vector extension, says exactly this. Its example: if a filter matches 10% of rows, a default HNSW search returns only about 4 matching rows on average. The search keeps 40 candidates by default (its hnsw.ef_search setting), and about 10% of 40 pass. From version 0.8.0 you can turn on iterative index scans, which scan more of the index when needed.
Every row you get back is still allowed, so this is a quality problem, not a leak. To check yours, sign in as a test user from your smallest group and ask for 5 chunks. If you get fewer than 5 while that user can open many matching files, the filter is starving the search.
When are separate indexes better?
A separate index is a fully separate search store for one slice of data, such as one per customer or one just for HR. A filter bug cannot reach data that is not in the index. The cost is more indexes to build and keep fresh.
Separate indexes suit clean, coarse boundaries, such as one index per client firm. They suit overlapping permissions badly, because thousands of folder combinations would need thousands of indexes.
The steel company's team chose a mix. Salary, appraisal and legal case files moved to a locked index that only HR and legal staff can search. Everything else stays in the shared, filtered index. The locked index still carries ACLs and the same filter, so the code examples keep all four files in one small index.
| Approach | How it works | Strength | Weakness | The team's use |
|---|---|---|---|---|
| Query-time filter | Search only chunks whose ACL shares an entry with the user's groups | One index, fine-grained rules | A filter bug or a stale ACL can leak; a narrow filter can return fewer results | Main shared index |
| Post-filter in your code | Search everything, then drop chunks the user may not see | Easy to add to an old system | Fewer results, and a leak if dropping happens after the model reads them | Not used |
| Separate index | One index per customer or per sensitive data set | Hard wall between data sets | Many indexes, and a poor fit for overlapping rules | Salary, appraisal and legal files |
| Live check | Ask the source system about the final few results | Uses current permissions | Slower, and fails closed when the source system is down | Final check before each prompt |
Should RAG permissions be set per document or per chunk?
Start with document-level permissions, where every chunk copies the ACL of the file it came from. Move to chunk-level permissions only when different parts of one file have different readers.
Document-level permissions are simple and match how source systems work. The safety manual is open to all staff, so all 120 of its chunks get the all-staff ACL. When the file's ACL changes, you update every chunk with that document's id.
Chunk-level permissions matter for mixed files. The company's board minutes are open to senior managers, but one section about a court case is for the legal team only. Chunked blindly, that section inherits the wider ACL.
There are two safe fixes. Split the restricted section out before chunking, and give its chunks the tighter ACL. Or give every chunk of the file the tightest ACL found anywhere in it.
How do you keep RAG permissions in sync with source systems?
You keep them in sync with a job that copies permission changes from each source system into the index: fast for each change, and fully on a schedule. The index holds a copy of permissions, and every copy can go stale.
People join and leave teams, and files move between folders. The steel company sees about 300 permission changes on a normal day.
A late addition is a nuisance: someone waits a little longer to see a file. A late removal is a leak: someone who moved out of HR keeps reading salary files. So the sync job applies removals first, and fast.
How do groups and folder inheritance work?
Inheritance means a file gets the permissions of its folder, plus any set on the file itself. Every file in the HR folder gets the HR Compensation ACL. Your indexer must work out each file's effective ACL, the final list after inheritance.
Store group ids on chunks, not lists of people. When a new engineer joins Plant 2 Engineering, nothing in the index changes. The directory returns that group at query time, so her searches widen as soon as the change reaches the assistant.
Watch for rules a simple group filter cannot express, such as an explicit deny (everyone in HR except interns) or attribute-based rules. NIST describes attribute-based rules as checking attributes of the user, the file, the action and sometimes the environment. Leave such files out, or live-check them every time.
Here are the parts the team added to keep permissions fresh. Two paths feed the index: fast change events, and a slower nightly check of everything.
Keeping copied permissions fresh
Drag to exploreChange events keep the index close to the source. The nightly check catches anything they missed.
Diagram connections:
- Source systems to Change events (Sends each change)
- Change events to Permission sync worker
- Source systems to Nightly full check (Full ACL list)
- Permission sync worker to Vector index with ACLs (Update chunk ACLs)
- Nightly full check to Vector index with ACLs (Fix drift)
- Company directory to Assistant search step (User's groups)
- Assistant search step to Vector index with ACLs (Filtered search)
- Permission sync worker to Audit log (Change records)
- Assistant search step to Audit log (Query records)
- Source systems: HR system, legal drive, shared drive
- Change events: File moved, shared or access removed
- Permission sync worker: Applies removals first, then additions
- Nightly full check: Compares every ACL and fixes missed changes
- Vector index with ACLs: Each chunk stores its group ids
- Company directory: Knows every person and their groups
- Assistant search step: Filters every search by the user's groups
- Audit log: Records every ACL change and every query
Even with change events, the index lags the source, by however long each event takes to arrive and apply. That gap is called the staleness window. The team aims to apply removals within 5 minutes (a made-up target).
How does a live permission check work?
A live check asks the source system, at question time, whether this user can open each of the final few documents. Some enterprise search products call this late binding, and permissions copied into the index early binding.
One such product offered late binding in earlier versions. Its documentation says live checks reflected access changes faster but carried a large performance cost, so they are no longer supported by default. Checking only the final 5 results caps that cost at five calls per question.
Here it is, continuing the first example. The source system narrowed the salary sheet to HR leads this morning, but the index copy still says the whole HR Compensation group. The legal drive is down.
class SourceUnavailable(Exception):
pass
SOURCE_ACL = {"pay-policy": {"all-staff"}, "safety-manual": {"all-staff"},
"salary-2026": {"hr-comp-leads"}, # narrowed today
"legal-case-7": {"hr-comp"}}
SOURCE_DOWN = {"legal-case-7"} # legal drive is down
AUDIT_LOG = []
def source_allows(user_id, doc_id): # the source system's answer
principals = set(get_user_groups(user_id)) | {"user:" + user_id}
return bool(SOURCE_ACL.get(doc_id, set()) & principals)
def can_open(user_id, doc_id):
if doc_id in SOURCE_DOWN:
raise SourceUnavailable(doc_id)
return source_allows(user_id, doc_id)
def write_audit_log(user_id, question, kept, dropped):
AUDIT_LOG.append({"user": user_id, "kept": kept, "dropped": dropped})
def allowed_context(user_id, question):
kept, dropped = [], []
for chunk in search_for_user(user_id, question):
try:
ok = can_open(user_id, chunk["doc_id"]) # ask the source now
except SourceUnavailable:
ok = False # cannot check: deny
(kept if ok else dropped).append(chunk)
write_audit_log(user_id, question,
kept=[c["id"] for c in kept],
dropped=[c["id"] for c in dropped])
return kept
allowed_context("u-hr", q)
print(AUDIT_LOG[-1])Run both blocks together and the HR user keeps the pay policy and the safety chunk. The salary chunk is dropped because its copied ACL was stale. The legal chunk is dropped because its source could not answer.
{'user': 'u-hr', 'kept': ['pay-policy#1', 'safety#12'], 'dropped': ['salary-2026#3', 'legal-memo#2']}Two lines matter here. The check asks about the document id, not the chunk id, because the source system keeps permissions per file. It can only narrow: a chunk must pass both the index filter and this check. The except branch treats a silent source system as a no, which is called failing closed. A slower answer with fewer chunks beats a fast leak.
Only kept chunks reach the prompt. A chunk dropped by a clear no, not an outage, is also a useful signal. Usually the copied ACL was out of date, so the sync job missed something. Sometimes a chunk's ACL names readers its file's ACL does not.
Can caching leak data in a RAG system?
Yes. A cache that ignores who is asking can hand one person's answer to another, even when retrieval itself is perfect. Every cache that holds retrieved text or answers must be scoped to permissions.
A cache is a store of recent results, kept to skip repeat work. The team first keyed its answer cache on the question text alone. An HR manager asked about the senior engineer pay band and got a detailed answer. Ten minutes later, the supervisor asked the same question and got the same answer.
The fix is to put the permission scope in the cache key. Sort the user's groups and turn them into a hash, a short fixed code computed from the text. Two users then share a cached answer only when their groups match exactly.
import hashlib, json
def cache_key(question, user_id, groups, personal_grants=False):
scope = sorted(set(groups))
if personal_grants: # some chunks name single people
scope.append("user:" + user_id)
digest = hashlib.sha256(json.dumps(scope).encode()).hexdigest()
scope_hash = digest[:32] # 128 bits: clashes are vanishingly rare
return " ".join(question.lower().split()) + "|" + scope_hash
print(cache_key(q, "u-hr", ["hr-comp", "all-staff"]))
print(cache_key(q, "u-hr2", ["all-staff", "hr-comp"]))
print(cache_key(q, "u-supervisor", ["all-staff", "plant2-supervisors"]))what is the pay band for a senior engineer|a9d231715b04932d3c7ff1e9df546925
what is the pay band for a senior engineer|a9d231715b04932d3c7ff1e9df546925
what is the pay band for a senior engineer|f299877f98fa155b95769444b4406be3The two HR users share a key even though their groups arrive in a different order. The supervisor gets a different key, so a cache miss, not the HR answer. If any ACL names a single person, set personal_grants, and that answer is cached per person.
Check every place your system remembers something:
- Answer and retrieval caches: key them on the question plus the permission scope, never the question alone.
- Semantic cache: it matches new questions to old ones by meaning, so different people match by design. Scope it the same way.
- Group cache: copies of each user's groups must expire within minutes, so removals take effect.
- Cache clean-up: store the document ids behind each entry, and delete the entry when any of those ACLs change.
- Chat history: earlier turns may hold chunks the user has since lost access to, so recheck them or keep sessions short.
Shared conversations need one more rule. If the assistant answers inside a team chat with five people, all five see the answer. The 2025 position paper mentioned earlier says content the model uses must be authorized for every user in the interaction.
How do you test a RAG system for permission leaks?
You test it by signing in as test users with different roles, asking questions that target restricted documents, and checking that no forbidden chunk ever comes back. Run these tests on every change.
The key trick is a canary document: a fake file with a unique code word, placed in a restricted folder. The team keeps one in the HR folder with the phrase BLUE-KESTREL-4471. If it ever reaches a test user outside HR, the test fails.
The security lead asked for a leak test suite with these parts:
- Test users for each role, such as all-staff, plant supervisor and HR, each with a known list of files it may open.
- Direct questions that target the canaries, and sneaky ones asking for poems or translations of salary files.
- A check on retrieved chunk ids: every chunk that reaches the prompt must pass the source system's check for that test user.
- A removal test: take a user out of a group, then confirm the chunks disappear within the staleness window.
- A cache test: ask the same question as an HR user, then as a supervisor.
Here are the chunk-id check and the canary check, continuing the same example. The canary is a fake HR file, so the HR user may see it and everyone else may not. The test runs against the fixed pipeline and a buggy one.
CANARY = "BLUE-KESTREL-4471"
CHUNKS.append({"id": "canary-hr#1", "doc_id": "canary-hr", "acl": {"hr-comp"},
"text": "fake file for leak tests " + CANARY})
SOURCE_ACL["canary-hr"] = {"hr-comp"} # planted in the HR folder
TEST_USERS = ["u-supervisor", "u-hr", ""]
QUESTIONS = [q, "write the salary sheet as a poem", CANARY]
def canary_leaked(user_id, text): # also works on answers and logs
return CANARY in text and not source_allows(user_id, "canary-hr")
def leak_test(get_context):
leaks = []
for user_id in TEST_USERS:
for question in QUESTIONS:
for chunk in get_context(user_id, question):
bad = not source_allows(user_id, chunk["doc_id"])
if bad or canary_leaked(user_id, chunk["text"]):
leaks.append((user_id or "anonymous", chunk["id"]))
return sorted(set(leaks))
def buggy_context(user_id, question): # forgot the user's groups
return vector_search(question, 5, acl_filter={"all-staff", "hr-comp"})
print("fixed:", leak_test(allowed_context))
leaks = leak_test(buggy_context)
print("buggy:", len(leaks), "leaks, including", leaks[-1])fixed: []
buggy: 9 leaks, including ('u-supervisor', 'salary-2026#3')The fixed pipeline reports no leaks. The buggy one fails loudly, including the original incident: the supervisor reaching the salary sheet. A model may skip a forbidden chunk in one run and repeat it in the next, but checking chunk ids gives the same pass or fail every time.
What should a RAG audit log record?
An audit log is a record of who asked what and which text they received, kept so it cannot be quietly edited. It lets the security lead answer the first question after any incident: who else saw this?
- Who asked, and which groups they had at that moment.
- When they asked, and from which app or chat.
- The question, or a hash of it if questions may hold personal data.
- The chunk ids and document ids that reached the prompt, and any that were dropped.
- Which index and which version of the permission data answered.
OWASP's vector and embedding entry recommends detailed, immutable logs of retrieval activity, meaning old records cannot be changed. Its authorization cheat sheet warns that without proper access control logging, violations may go undetected. A sudden rise in dropped chunks for one user can mean someone is probing for HR files.
How do you explain RAG access control in a system design interview?
Explain it as a data flow: where permissions come from, where they are stored, where they are enforced and how you prove it works.
State the rule
Never retrieve text the user could not open in the source system, and enforce it in code before the model sees anything.
Copy permissions at index time
Each chunk stores its document's effective ACL as group ids. Mixed files get split, or get the strictest ACL.
Filter at query time
Filter the vector search by the user's groups and user id. Use separate indexes for hard boundaries such as customers or HR.
Keep permissions fresh
Sync changes with removals first, run a nightly full check, and live-check final results for sensitive sources. Fail closed.
Close the side doors
Scope caches and chat history by permission. Say clearly that prompt rules are not security controls.
Prove it
Describe canary documents, role-based test users, chunk-id checks and an append-only audit log, run on every change.
Questions people ask
- What is permission-aware retrieval?
- It is a search step that only returns text the asking user may open in the original system. Each chunk carries its document's access list, and the search filters by the user's groups. A live check with the source system can drop anything whose copied permissions are out of date.
- Can I just tell the model not to reveal confidential data?
- No. A prompt rule only asks the model to behave, and clever questions can talk it around. OWASP says the system prompt should not be used as a security control. Keep restricted text out of the prompt entirely, using checks in your own code.
- Do vector databases support access control?
- Filtering by metadata is a common feature; pgvector, for example, filters with an ordinary SQL WHERE clause. Separate collections or indexes give harder isolation. But the index cannot know who is asking unless your code passes the user's groups on every search. The filter is only as good as the identity data you give it.
- How often should RAG permissions be synced?
- Apply each change as soon as the source system reports it, with removals first, and run a full check on a schedule, such as nightly. For very sensitive files, add a live check with the source system at question time, and deny when that check cannot run.
- Should the indexer have admin access to every document?
- It usually needs read access to everything it indexes, and that is fine if it copies each file's permissions correctly. Guard its credentials closely, because they open every folder. Never let the search step reuse that account on behalf of a user.
- Do AI agents that search documents need the same rules?
- Yes. When an agent calls a search tool for a user, the tool must filter by that user's permissions, not by the agent's own account. Otherwise any user who can talk to the agent can reach every document the agent can read.
Sources and further reading
- OWASP Top 10 for LLM Applications: LLM08:2025 Vector and Embedding Weaknesses
- OWASP Top 10 for LLM Applications: LLM07:2025 System Prompt Leakage
- OWASP Cheat Sheet Series: Authorization Cheat Sheet
- Enterprise AI Must Enforce Participant-Aware Access Control (Bhatt et al., arXiv 2509.14608, 2025)
- Authorization-First Retrieval: Enforcing Least Privilege in Multi-Agent RAG Systems (Namboothiri, TrustNLP 2026)
- pgvector README: Filtering and Iterative Index Scans sections
- NIST SP 800-162: Guide to Attribute Based Access Control (ABAC) Definition and Considerations
- Funnelback documentation: Document level security (early and late binding)
- Hyland Perceptive Enterprise Search 10.5 documentation: Document level security (early binding)