← All posts

ARCHITECTURE

Access control in RAG: who gets to see what

When corporate documents are handed to a RAG system, the existing permission model usually does not travel with them. An implementation note on which layer enforces access, how identity reaches the query, and when an entitlement change actually reaches the index.

Documents inside an organization are never a flat pile. Not everyone opens the HR folder, legal correspondence is scoped to a named list, finance spreadsheets have a countable set of readers. That structure accumulated over years across file servers, SharePoint libraries, LDAP groups and SSO roles. Then those documents are handed to a RAG system. The first question to ask is where the existing permission model went during that transfer.

In most first builds the answer is nowhere. The document is chunked, embedded, written into a single index. Permission data from the source system travels through none of those steps, so the index itself is a permissionless plane. This is an implementation note on how to put the layers back.

How a single index flattens the model

In a naive setup similarity search runs over the entire corpus. The top k chunks come back, and at query time nothing says which library, folder or access list they came from. Those chunks are handed to the model as context, and the model treats everything it is given as answerable. Content from a file the user could never open re-emerges in the answer, rewritten in natural language.

The OWASP Gen AI Security Project names this under LLM08:2025, Vector and Embedding Weaknesses: sharing one vector database across multi-tenant or multi-class environments produces context leakage between users or queries. In practice this is not a model behavior problem, it is a classic authorization problem, and the entry is commonly discussed alongside the Broken Access Control class from the OWASP Top 10. Its first recommended mitigation is equally direct: fine-grained access controls and permission-aware vector and embedding stores, with strict logical and access partitioning of datasets inside the store.

The OWASP RAG Security Cheat Sheet goes further and requires the metadata to live at chunk level rather than document level: classification, owner, permitted roles and permitted tenants stored alongside every vector chunk. The reasoning is practical. A permission attached only to the document can lose its association during chunking, and what remains are chunks with nothing to filter on.

Where the filter is applied

Access control can be enforced at three distinct points, and they are not equivalent.

  • Partitioning at index time: different classification levels go into separate indexes, collections or namespaces. This is the strongest isolation, because unauthorized content is simply not present in the searched set. The cost is flexibility — a query spanning mixed entitlements has to fan out, and changes in the source system require relocation.
  • Metadata filtering at query time: content stays in one index and the filter predicate runs together with the search. Operating cost is lowest here, because the content stays in one place and the filter runs inside the engine.
  • Post-retrieval filtering: search runs unfiltered and the returned list is trimmed in application code. This is the weakest position from a security standpoint, and it also produces measurable recall loss.

Azure AI Search names this distinction formally through its vectorFilterMode parameter (see Azure AI Search — Filters in vector search). In preFilter the filter predicate is applied during HNSW graph traversal, and the documentation states that prefiltering guarantees k results are returned if they exist in the index. In postFilter each shard is traversed without a filter and the predicate is applied afterwards; for highly selective filters this reduces recall and produces false negatives. strictPostFilter, in preview, applies the filter after the global top-k is found and can return zero results with selective filters. Indexes created after roughly October 15, 2023 default to preFilter.

The conclusion that matters here: security filters are typically highly selective. A given user usually sees a small percentage of the corpus. That is exactly the regime where post-filtering quietly drops results. Prefiltering pays for correctness in latency, and Microsoft published the numbers in the same document: at 1M vectors and 1536 dimensions, prefiltering is roughly 30% slower when more than 30% of the dataset is filtered, and roughly 7x slower when less than 2% is. At 100,000 vectors, filtering below 0.1% makes prefiltering about 50% slower. Latency budget and security model have to be planned together.

Security trimming with metadata

The most common concrete pattern writes permitted group identifiers onto every chunk as a filterable collection. In Azure AI Search’s security filter pattern (Azure AI Search — Security filters for trimming results) the field is defined like this.

json
{
  "name": "group_ids",
  "type": "Collection(Edm.String)",
  "filterable": true,
  "retrievable": false
}

Setting retrievable to false is deliberate: permission metadata is there to filter with, not to hand back in the response payload. At query time the filter takes this shape.

text
group_ids/any(g: search.in(g, 'grp-finance, grp-legal-readers'))

The same documentation warns that building an equality chain like Id eq ’id1’ or Id eq ’id2’ instead is error-prone and difficult to maintain, and that with hundreds or thousands of values response times stretch into many seconds, whereas search.in is expected to stay subsecond. It also states the boundary plainly: there is no authentication or authorization through the security principal — the principal is just a string. The filter assumes the identity was derived correctly upstream; authentication still belongs to the layer above.

On the relational side Postgres row level security does the same job. Supabase’s pgvector guidance (Supabase — RAG with Permissions) applies policies to the document_sections table, and semantic search continues to honor them.

sql
alter table document_sections enable row level security;

create policy "read permitted sections"
on document_sections for select
to authenticated
using (
  document_id in (
    select id from documents
    where owner_id = auth.uid()
  )
);

Identity arrives via auth.uid() over REST, or through a current_setting() session variable on a direct Postgres connection. The documentation adds a specific caveat: with a Foreign Data Wrapper, RLS is latency-sensitive, and query plan analysis before production is required.

Choosing engine-side filtering over application-side trimming is not only about speed. Azure’s stated rationale is that in-engine filtering also removes custom code for nested group resolution and multi-level ACL traversal. Once written, that code becomes a second copy of the organization’s permission model, and two copies drift.

Carrying identity into the query

Where the filter value comes from matters as much as the filter. Trusting a group list sent by the client means the filter enforces nothing: the trim depends on a value the caller chooses. The correct flow derives identity from a verified token.

On Azure AI Search’s native ACL path (Azure AI Search — Document-level access control) the user token travels in the x-ms-query-source-authorization header. The service extracts user, group and scope claims from the token, compares them against permission metadata in the index, and returns only authorized documents. Separately, the calling application is checked for the Search Index Data Reader role — a two-stage check. These capabilities sit in a preview REST API version; confirm the exact version string against current Microsoft Learn documentation before writing it into a call. The service documents four official approaches: security filters (GA, API-agnostic), POSIX-like ACLs with RBAC scopes, Microsoft Purview sensitivity labels, and SharePoint M365 ACLs, the last three in preview.

On Google Vertex AI Search (Google Cloud — Vertex AI Search: data source access control), ACLs are supplied at ingestion through the acl_info field in document metadata, structured as readers → principals → group_id or user_id. Identity comes from Google Identity or Workforce Identity Federation (Microsoft Entra ID, Okta, Ping), and the google.subject attribute must map to the email field in the external provider. Two limits shape the design directly: 3000 readers per document, and the fact that ACL configuration is fixed when the data store is created and cannot be changed afterwards.

What happens when entitlements change

This is the most frequently missed part of the architecture. When a user is removed from a group, the effect in the source system is immediate; in the index it is not. Azure’s documentation states it explicitly: a timing lag occurs before the preview API recognizes changes to those access or permission restrictions. Permission changes in the source system — Entra group membership, ADLS Gen2 ACLs, Purview label assignments, SharePoint ACLs — are only reflected in search results after that metadata is synchronized to the index, via an indexer run, a push-API update, or a Purview refresh.

For SharePoint the distinction is finer still. Changes on items with unique permissions are picked up incrementally on each successful indexer run, while changes inherited from a parent scope — site, library, list or folder — require an explicit refresh: /resync with options: ["permissions"], or /resetdocs. A permission tightened at library level may therefore not propagate on its own, even with the indexer running on schedule.

Elasticsearch addresses the same problem with a different architecture (Elasticsearch — Document level security and connector access control sync). There are two separate sync types: content sync, into an index prefixed search-, and access control sync, into a hidden index prefixed .search-acl-filter-<INDEX-NAME>. The field on content documents is _allow_access_control; access is granted when at least one entry in the user’s access control document matches an entry in that field, and an empty value closes the document to everyone.

json
{
  "title": "Q3 procurement note",
  "body": "...",
  "_allow_access_control": [
    "group:finance-readers",
    "group:procurement"
  ]
}

The documented method for handling permission changes is to set an expiration on the Elasticsearch API key and schedule recurring access control syncs; if a user’s permissions change, the API key needs to be updated or recreated. In general DLS behavior, when a user holds multiple roles against the same index the role queries are combined with OR — a single matching role makes the document visible. DLS does not apply to write APIs, and since it runs on every query it carries a small performance cost.

The design rule that follows is short: refresh interval is a security parameter. The propagation time from an entitlement change to the index belongs in the system’s written contract, chosen per classification level.

Metadata dropped during chunking

Partitioning strategy in multi-tenant stores belongs in the same picture. Qdrant recommends a single collection per embedding model with payload-based partitioning (Qdrant — Multitenancy); since v1.11.0 the is_tenant: true parameter on a keyword index co-locates a tenant’s vectors, and at scale payload_m in hnsw_config combined with a global m of 0 gives per-tenant independent indexing. The same documentation notes that global queries without a group filter slow down because they must scan all groups. Pinecone recommends namespaces for tenant isolation and defines metadata operators $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and and $or, with only $and and $or permitted at the top level of the filter expression. Weaviate isolates each tenant in its own shard with its own vector index. Amazon Bedrock Knowledge Bases builds access control from metadata filters and allows up to 10 KB of custom metadata per document.

Whichever store is chosen, one configuration step is commonly missed. Azure’s documentation notes that when a skillset chunks the document — Text Split skill or integrated vectorization — permission metadata fields must be carried from indexer field mappings into index projections. For Purview labels the wording is explicit: without this projection, chunk-level references aren’t filtered. Chunking, misconfigured, drops the permission metadata and leaves unfiltered chunks behind. This is invisible from the outside: the index builds, search runs, only the filter matches nothing meaningful.

Audit trail and the generation stage

The other half of access control is the record of which query touched which document. The OWASP RAG Security Cheat Sheet enumerates it: log every retrieval with the querying agent or user’s identity and the access control metadata of the retrieved chunks, and cover the full pipeline — query received, chunks retrieved with document IDs and access control metadata, model input assembled, model output generated, and any tool calls triggered. The commonly skipped part is caching: cache hits must be logged at the same level of detail as fresh retrievals, otherwise the trail breaks. Every insert, update and delete against the index should be recorded with a timestamp and the identity that made it. LLM08:2025’s fourth mitigation points the same way: maintain detailed immutable logs of retrieval activities.

The last layer is generation. Even with correctly filtered retrieval, a multi-step flow can pull out-of-scope content into the answer through a summary, an intermediate note or a tool output. OWASP’s normative measures for this stage: validate all model outputs before returning them, apply policy filters and redaction for PII and secrets, redact dynamically according to the querying user’s access level, and sign source attribution data so it cannot be tampered with after generation. In practice that means every chunk an answer rests on carries its identifier forward, and the final check runs in a layer independent of the model that produced the answer.

There is no known public measured study on content bleeding through summarization. Treat it as a design assumption rather than an empirical finding: anything that enters the model’s context can surface in the answer, so the control belongs at the retrieval layer.

Access control in RAG is not a new security discipline. It is the existing permission model being carried onto a new data path. The whole thing reduces to four questions. Does permission metadata live at chunk level? Is the filter applied inside the engine, during search traversal? Is identity derived from a verified token rather than supplied by the client? And how many minutes pass between a permission change in the source system and its effect on the index? A system that can answer those four in writing is auditable. One that cannot has no answer to the question of what the index returns either.

Sources

  • OWASP Gen AI Security Project — LLM08:2025 Vector and Embedding Weaknesses
  • OWASP — RAG Security Cheat Sheet
  • Azure AI Search — Filters in vector search (vectorFilterMode, prefilter/postfilter latency figures)
  • Azure AI Search — Security filters for trimming results
  • Azure AI Search — Document-level access control (ACLs, RBAC scopes, Purview labels, SharePoint indexer)
  • Supabase — RAG with Permissions (pgvector and row level security)
  • Google Cloud — Vertex AI Search: data source access control
  • Elasticsearch — Document level security and connector access control sync
  • Qdrant — Multitenancy
  • Pinecone — Namespaces and metadata filtering
  • Weaviate — Multi-tenancy
  • Amazon Bedrock Knowledge Bases — Metadata filtering

More posts

Have a system to build, or one to fix in place?

No deck needed. 20 minutes. The rest is up to you.