> How RAG assistants leak documents and how to stop it: ACLs on every chunk, pre-filtering, row-level security in pgvector, permission sync and leak tests.
>
> Web page: https://balazscsorba.com/blog/rag-access-control-permissions · Language: English · Also available in: [Deutsch](https://balazscsorba.com/de/blog/rag-access-control-permissions.md) · [Magyar](https://balazscsorba.com/hu/blog/rag-access-control-permissions.md)
> Author: Balázs Csorba · Published: 2026-10-17 · Keywords: rag access control, permission aware rag, document level security rag, rag multi tenancy, vector database access control, pgvector row level security, security trimming azure ai search, openfga rag authorization, qdrant multitenancy, pinecone namespaces multitenancy, owasp llm08 vector and embedding weaknesses, rag data leakage

[Blog](https://balazscsorba.com/blog)/Retrieval & search

# Access control for RAG: permission-aware retrieval without leaking documents

How RAG assistants leak documents and how to stop it: ACLs on every chunk, pre-filtering, row-level security in pgvector, permission sync and leak tests.

[Balázs Csorba](https://balazscsorba.com/about)·October 17, 2026·9 min read

-   RAG
-   Access control
-   Security
-   Multi-tenancy
-   Vector search

![Seven cards in a row, six with a lock badge, one bare card sending an arrow outward.](https://balazscsorba.com/images/blog/rag-access-control-permissions/cover.webp?v=28a7a7cd52)

## Key takeaways

-   Enforce permissions in the retrieval query, before any text reaches the model; a system prompt is not an access control.
-   Every chunk inherits the ACL of its source document, ideally as group IDs, plus a tenant ID and the time the ACL was last synced.
-   Filtered vector search has a recall trap: pgvector applies the filter after the HNSW scan, so a 10% filter can leave about 4 results unless iterative scans are on.
-   Permission changes only take effect after they reach the index, so sync ACLs separately from content and check high-risk sources at query time.
-   Caches, summaries, memory and embeddings are derived data and need the same permissions as the documents they came from.

On this page

1.  [How does a RAG system leak documents?](https://balazscsorba.com/#how-rag-leaks)
2.  [Where should authorization happen?](https://balazscsorba.com/#where-to-enforce)
3.  [Separate indexes, pre-filters or post-filters?](https://balazscsorba.com/#patterns)
4.  [What should each chunk carry?](https://balazscsorba.com/#chunk-metadata)
5.  [How do you do it in pgvector?](https://balazscsorba.com/#pgvector-rls)
6.  [What do Qdrant, Pinecone and Azure AI Search offer?](https://balazscsorba.com/#vector-databases)
7.  [How do you keep permissions in sync?](https://balazscsorba.com/#permission-sync)
8.  [What about caches, summaries and embeddings?](https://balazscsorba.com/#derived-data)
9.  [How do you test that nothing leaks?](https://balazscsorba.com/#leak-tests)
10.  [Sources](https://balazscsorba.com/#sources)

Listen to this article

0:000:00

The fastest way to build an internal RAG assistant is to index everything with one service account and let the model answer. It is also the fastest way to let an apprentice ask for "this year's salary bands" and get a cited answer from an HR spreadsheet. The source system had permissions; the index flattened them.

OWASP lists this as LLM08:2025, vector and embedding weaknesses: weak access controls on embeddings, leaks between tenants in shared vector databases, and embedding inversion. This post is the practical side: where to enforce permissions, what each chunk must carry, how to do it in pgvector, Qdrant, Pinecone and Azure AI Search, how to keep permissions in sync, and how to test that nothing leaks.

## How does a RAG system leak documents?

In the systems I review, leaks rarely come from a clever attack. They come from places where permissions were never attached:

-   **An index without ACLs.** Content was crawled with an admin or service account, and chunks carry no information about who may read them.
-   **Filters that fail open.** A missing group claim produces an empty filter, and an empty filter matches everything.
-   **Stale permissions.** Someone was removed from a SharePoint site last week; the index still lists them.
-   **Derived data.** A semantic cache, a conversation memory or a summary that mixes several documents answers a second user from material the first user was allowed to see.
-   **Over-privileged agents.** A retrieval tool runs with the service account rather than the user's identity, so the agent can fetch what the user cannot.

The last point is the general rule from [AI agents are identities](https://balazscsorba.com/blog/ai-agent-identity-least-privilege): retrieval should act on behalf of the user, with the user's rights, not with the rights of the system that hosts it.

## Where should authorization happen?

In the retrieval query, before anything reaches the model. Once a chunk is in the prompt, no instruction reliably keeps it out of the answer, and prompt injection can ask for it directly. The flow is the same in every stack: authenticate the user, resolve their groups, add those groups as a filter to the search, and pass only the surviving chunks to reranking and generation.

If group resolution fails, the request fails; it never falls back to an unfiltered search.

**The system prompt is not an access control**

"Do not reveal HR documents to non-HR staff" in a system prompt is a hope, not a control. Anything in the context window can end up in the output, especially under the injection patterns described in [the lethal trifecta](https://balazscsorba.com/blog/prompt-injection-lethal-trifecta-patterns). Keep unauthorized text out of the context instead.

## Separate indexes, pre-filters or post-filters?

There are four patterns, and most real systems combine two: hard isolation between tenants, and document-level filters inside each tenant.

Pattern

How it works

Strength

Weakness

Index or namespace per tenant

Queries target one tenant's partition

A missing filter cannot cross tenants; delete to offboard

No document-level rights inside a tenant

Metadata pre-filter

Allowed groups stored per chunk, filter in the query

Unauthorized chunks never become candidates

ACLs must be synced into the index

Post-filter with authorization service

Retrieve candidates, then check each against OpenFGA or similar

Always current permissions

Over-fetching, empty pages, latency

Database row-level security

Postgres policies filter rows for the session's role

Default deny, enforced below the application

Only for Postgres; recall trap with ANN indexes

OpenFGA describes the post-filter variants: check each candidate for a `can_view` relation, or call list-objects once to get every document the user can read and intersect it with the candidates. It recommends per-document checks for small sets and list-objects when the set is larger. The cost of any post-filter is that you have to retrieve more than you need, because some candidates will be dropped.

## What should each chunk carry?

Chunks inherit the permissions of their source document. Store groups rather than individual users where possible; Microsoft gives the same advice for ACL-secured content in Azure AI Search, and Pinecone calls filtering by large lists of user IDs an anti-pattern, with each `$in` operator limited to 10,000 values. A minimal payload:

```
{
  "chunk_id": "sp-hr-0412#7",
  "tenant_id": "acme",
  "doc_id": "sp-hr-0412",
  "source": "sharepoint://hr/salary-bands-2026.xlsx",
  "allowed_groups": ["grp-hr", "grp-exec"],
  "classification": "confidential",
  "acl_version": "2026-10-16T22:05:11Z",
  "text": "..."
}
```

The `acl_version` field is what makes sync auditable: you can see how old each chunk's permissions are and find chunks that missed an update. For anything derived from several documents, such as a summary, use the intersection of their groups, not the union.

## How do you do it in pgvector?

Postgres row-level security puts the filter below the application. Once enabled on a table, all normal access must be allowed by a policy, and with no policy the default is deny. Superusers, roles with BYPASSRLS and normally the table owner bypass it, so the application must connect as a separate role, and `FORCE ROW LEVEL SECURITY` makes the owner subject to the policy too.

```
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks FORCE ROW LEVEL SECURITY;

-- The app sets the caller's tenant and groups per transaction.
CREATE POLICY chunk_read ON chunks FOR SELECT TO rag_app
  USING (
    tenant_id = current_setting('app.tenant_id')
    AND allowed_groups && string_to_array(current_setting('app.groups'), ',')
  );

BEGIN;
SET LOCAL app.tenant_id = 'acme';
SET LOCAL app.groups = 'grp-sales,grp-all';
SET LOCAL hnsw.iterative_scan = relaxed_order;   -- pgvector 0.8.0+
SELECT chunk_id, text FROM chunks
ORDER BY embedding <=> $1 LIMIT 20;
COMMIT;
```

The last setting matters more than it looks. pgvector applies filters after the approximate index is scanned, and with the default `hnsw.ef_search` of 40, a filter that matches 10% of rows leaves about 4 results. A permission filter is exactly such a filter, and the symptom is not a leak but an assistant that "cannot find" documents for users with few rights. Iterative index scans, added in pgvector 0.8.0, keep scanning until enough rows match, up to `hnsw.max_scan_tuples` (20,000 by default). For tenants, a B-tree index on `tenant_id` or list partitioning by tenant also helps. My comparison of [pgvector and vector databases](https://balazscsorba.com/blog/pgvector-vs-vector-databases) covers when this is enough.

## What do Qdrant, Pinecone and Azure AI Search offer?

-   **Qdrant** recommends one collection with a tenant field over a collection per tenant. A keyword payload index with `is_tenant` set to true (from v1.11) stores each tenant's vectors together, every query carries a `must` filter on the tenant, and from v1.16 tiered multitenancy promotes large tenants to their own shard. Document-level groups go into the payload as another filter.
-   **Pinecone** recommends one namespace per tenant in a serverless index. Namespaces are stored separately, which Pinecone describes as physical isolation, and offboarding is deleting the namespace. Queries are billed by namespace size, so filtering a shared 100 GB namespace costs 100 times more than querying a 1 GB tenant namespace.
-   **Azure AI Search** supports security filters, plain string comparison on an identity field that your application fills, and in preview native document-level ACLs and RBAC scopes, SharePoint ACLs and Purview sensitivity labels, enforced by passing the user's Entra token in the `x-ms-query-source-authorization` header. Microsoft is explicit that security filters are string matching, not authentication, so your application must verify the identity first.

## How do you keep permissions in sync?

An index is a copy, and copies go stale. Microsoft's documentation says it directly: permission changes in the source system only appear in search results after the metadata is synchronized to the index, and for SharePoint, changes inherited from a parent site, library or folder need an explicit refresh. Every other stack has the same property, documented or not. Three measures keep the window small:

1.  **Sync ACLs separately from content.** Updating `allowed_groups` is a metadata write, not a re-embed. Run it far more often than content indexing, and alert when `acl_version` falls behind.
2.  **Resolve groups at query time.** Store groups on chunks and resolve the user's current group membership from the identity provider per request, so removing someone from a group takes effect immediately.
3.  **Re-check high-risk sources live.** For HR, legal or board material, add a post-filter against the source system or an authorization service for the final candidates, accepting the extra latency for those collections only.

## What about caches, summaries and embeddings?

Everything computed from documents inherits their sensitivity. A semantic cache must include the user's permission set in its key, or one user's answer is served to another; I described the scoping in [semantic caching](https://balazscsorba.com/blog/semantic-caching-llm-responses). Conversation memory belongs to one user. Summaries, entity descriptions and chunk contexts generated across several documents get the intersection of their groups.

Embeddings are not anonymous either. Morris and colleagues showed in 2023 that an iterative inversion method recovered 92% of 32-token inputs exactly from their embeddings, and recovered full names from clinical notes. Give the vector store the same network isolation, encryption and access logging as the source documents, and include it in deletion requests.

## How do you test that nothing leaks?

Test the retrieval layer, not the answers. A model may decline to quote a chunk it received, which hides the bug. Plant canary documents with unique marker strings, each visible to exactly one group, and assert on the retrieved chunk IDs:

```
import pytest

PERSONAS = {"sales": ["grp-sales"], "hr": ["grp-hr"], "none": []}
CANARIES = {"canary-hr": "grp-hr", "canary-sales": "grp-sales"}
QUERIES = ["salary bands", "canary report", "confidential", "*"]

@pytest.mark.parametrize("persona,groups", PERSONAS.items())
@pytest.mark.parametrize("query", QUERIES)
def test_no_canary_leaks(persona, groups, query):
    hits = retrieve(query, tenant="acme", groups=groups, k=50)
    for doc_id, owner_group in CANARIES.items():
        if owner_group not in groups:
            assert all(h.doc_id != doc_id for h in hits), (persona, query, doc_id)

def test_missing_groups_fail_closed():
    with pytest.raises(PermissionError):
        retrieve("anything", tenant="acme", groups=None, k=10)
```

Add two more checks: revocation latency, by removing a test user from a group and measuring how long until the canary disappears, and tenant isolation, by querying with a valid token for tenant A against tenant B's namespace. Run the suite in CI and after every change to the sync job. Access control belongs next to the defences against [RAG poisoning](https://balazscsorba.com/blog/rag-poisoning-knowledge-base-attacks): both protect the knowledge base, one against reading, the other against writing.

## Sources

1.  [OWASP GenAI Security Project: LLM08:2025 Vector and Embedding Weaknesses](https://genai.owasp.org/llmrisk/llm082025-vector-and-embedding-weaknesses/)
2.  [PostgreSQL documentation: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
3.  [pgvector README: filtering and iterative index scans](https://github.com/pgvector/pgvector)
4.  [Qdrant: Multitenancy and partitions guide](https://qdrant.tech/documentation/guides/multiple-partitions/)
5.  [Pinecone: Implement multitenancy using namespaces](https://docs.pinecone.io/guides/index-data/implement-multitenancy)
6.  [Microsoft Learn: Document-level access control in Azure AI Search](https://learn.microsoft.com/en-us/azure/search/search-document-level-access-overview)
7.  [OpenFGA: RAG authorization use case](https://openfga.dev/docs/use-cases/rag-authorization)
8.  [Morris et al.: Text Embeddings Reveal (Almost) As Much As Text (EMNLP 2023)](https://arxiv.org/abs/2310.06816)

## Frequently asked questions

What is permission-aware RAG?

A retrieval-augmented generation system that only retrieves chunks the asking user is allowed to read in the source system. The user's identity is resolved to groups, and those groups filter the search before any text is passed to the model.

Should I filter before or after the vector search?

Before, whenever the store supports it. Pre-filtering in the query keeps unauthorized chunks out of the candidate set entirely. Post-filtering with an authorization service also works, but you have to over-fetch and you risk empty result pages.

Is one index per tenant safer than metadata filters?

It gives stronger isolation: a query cannot reach another tenant's data even if a filter is missing, and offboarding is deleting the namespace. Pinecone recommends one namespace per tenant. Within a tenant you still need document-level filters.

Can embeddings leak the original text?

Yes, to a degree. Morris and colleagues showed in 2023 that an iterative inversion method recovered 92% of 32-token inputs exactly from their embeddings. Treat the vector store with the same protection as the source documents.

How do I test that permissions hold?

Plant canary documents visible to only one group, run a fixed set of queries as users from every group, and assert that no canary chunk appears in any retrieved set it should not. Run it in CI and after every permission sync change.

Written by Balázs Csorba

Senior fullstack & AI engineer in Styria, Austria – 10+ years of Vue, Nuxt, Node.js and PHP, now building tooling for AI agents.

[AI engineering & MCP servers →](https://balazscsorba.com/expertise/ai-engineer)[About me →](https://balazscsorba.com/about)

## More articles

-   [Choosing an embedding model in 2026: leaderboards, costs and a bake-off on your own data](https://balazscsorba.com/blog/embedding-model-selection-2026)
-   [Contextual retrieval and late chunking: giving RAG chunks back their context](https://balazscsorba.com/blog/contextual-retrieval-late-chunking)
-   [PDF parsing for RAG: layout pipelines, OCR and vision models compared](https://balazscsorba.com/blog/pdf-document-parsing-for-rag)
-   [Multilingual RAG for German and Hungarian: embeddings, compounds and cross-lingual search](https://balazscsorba.com/blog/multilingual-rag-german-hungarian)

## Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.

[Get in touch](mailto:contact@balazscsorba.com) [Connect on LinkedIn](https://www.linkedin.com/in/balazs-csorba)
