bigonotes
AIRAG

Permissions

Why access control in RAG must filter chunks at retrieval time, never after, and the per-tenant versus shared-index patterns.

2 min readLast updated

In most real applications, not every user should see every document. One customer must not see another's data, and an intern should not see board minutes. The rule is simple and non-negotiable: filter by the user's access at retrieval time, not after.

The wrong approach#

The tempting shortcut is to retrieve everything and tell the model not to show restricted content. This does not work.

By then the model has already read the restricted content in its context. It might leak it, paraphrase it, or let it shape the answer without quoting it. A prompt instruction is not a security control, and prompt injection can override it.

The right approach#

Keep restricted content out of the prompt entirely:

  1. At index time, store access metadata on every chunk: which tenant it belongs to, which user groups can see it, and its permission level.
  2. At retrieval time, filter the vector search so it returns only chunks the current user is allowed to see.
On the wrong path, all chunks go into the prompt and the model is asked to hide some; on the right path, a filter on tenant and group runs inside the search, so only allowed chunks reach the prompt

With pgvector, the filter is an ordinary WHERE clause in the same query as the similarity search.

SELECT id, text
FROM chunks
WHERE tenant_id = %(tenant_id)s
  AND allowed_groups && %(user_groups)s
ORDER BY embedding <=> %(query_vector)s
LIMIT 5;

Take the tenant and groups from the authenticated session on the server, never from anything the user or the model can change.

Two patterns#

Pattern Isolation Cost Risk
Separate index per tenant Strongest, no cross-tenant mixing More expensive at scale Low
Shared index with mandatory filters Depends on the filter Cheaper One missed filter leaks data

With a shared index, the filter must run on every single query without exception. Put it in one shared retrieval function that every caller must use, so no code path can skip it.

Short line#

Store access metadata on every chunk and filter the vector search by the user's access, so restricted content never enters the prompt. Telling the model to hide data is not a security control.

Key takeaways#

  • Filter by access at retrieval time, never after.
  • A model that has seen restricted content can leak, paraphrase or be influenced by it.
  • Store tenant, groups and permission level on every chunk at index time.
  • A separate index per tenant isolates best but costs more at scale.
  • A shared index is cheaper, but the filter must run on every query. One miss leaks data.
Revision notes · 1 min

Core idea#

Filter by the user's access at retrieval time, not after. Restricted content must never enter the prompt.

Key points#

  • Wrong: retrieve everything and tell the model to hide restricted content.
  • A model that has read restricted content can leak, paraphrase or be influenced by it.
  • A prompt instruction is not a security control.
  • At index time, store tenant, user groups and permission level on every chunk.
  • At retrieval time, filter the vector search to chunks the user may see.
  • Take tenant and groups from the authenticated session on the server.

Two patterns#

Pattern Isolation Cost
Separate index per tenant Strongest More expensive at scale
Shared index with mandatory filters Depends on the filter Cheaper

Pitfalls#

  • Do not skip the filter on any query with a shared index. One miss leaks data.
  • Do not let the user or the model set the filter values.
  • Put the filter in one shared retrieval function.

Short line#

Store access metadata on every chunk and filter the search, so restricted content never reaches the prompt.

Found a mistake? Email ask@bigonotes.com