DEV Community

Cover image for Enforcing Document-Level Permissions in a RAG Pipeline: Code-Level Patterns
Mira roy
Mira roy

Posted on

Enforcing Document-Level Permissions in a RAG Pipeline: Code-Level Patterns

A RAG pipeline can respect every permission in your document store and still leak information during retrieval.

That happens because access controls attached to files in SharePoint, S3, Google Drive, or an internal knowledge system do not automatically survive the journey through parsing, chunking, embedding, indexing, and vector retrieval.

If a confidential document becomes twenty anonymous chunks in a vector database, your application may have lost the information it needs to decide who is allowed to retrieve them.

That makes RAG permission enforcement an application-level security requirement, not simply a storage configuration problem.

This article focuses on implementation patterns for carrying permissions through the retrieval pipeline.

Storage ACLs Don't Automatically Protect Vector Retrieval

Consider a source document:

finance-q4-plan.pdf
Allowed:

  • finance-team
  • CFO Classification:
  • confidential

Your ingestion pipeline might turn it into:

Chunk 1 → vector
Chunk 2 → vector
Chunk 3 → vector

If those vectors contain only:

{
"text": "Projected acquisition budget...",
"embedding": [0.12, 0.84, 0.31]
}

the vector database has no idea that the original file was confidential.

A user can submit a semantically related query, and similarity search may return the chunk.

The vector database did exactly what you asked.

The authorization layer failed.

A more secure RAG architecture therefore needs to preserve authorization context alongside every retrievable chunk.

Tag Permission Metadata at Index Time

The simplest pattern is attaching security metadata while documents are being indexed.

For example:

chunk = {
"id": "finance-q4-plan-003",
"text": "Projected acquisition budget...",
"metadata": {
"document_id": "finance-q4-plan",
"classification": "confidential",
"allowed_groups": ["finance-team"],
"allowed_users": ["cfo@company.com"]
}
}

This metadata should be derived from an authoritative source where possible.

Avoid maintaining completely separate permission rules inside the RAG application if your organization already has an identity or document-access system.

A useful ingestion flow looks like this:

Source Document
↓
Read ACL / classification
↓
Parse document
↓
Create chunks
↓
Copy authorization metadata to every chunk
↓
Generate embeddings
↓
Store vector + metadata

Every chunk should retain enough information for the retrieval layer to make an authorization decision.

Depending on the application, metadata might include:

{
"tenant_id": "company-123",
"department": "finance",
"classification": "confidential",
"allowed_roles": ["finance_manager"],
"allowed_groups": ["finance-team"],
"document_id": "doc-781"
}

Be careful about relying only on broad labels such as department.

Real permission models often require multiple dimensions.

Filter Before Documents Reach the LLM

The important rule is straightforward:

Unauthorized chunks should never reach the generation stage.

Suppose the authenticated requester has this security context:

requester = {
"user_id": "user-52",
"groups": ["engineering"],
"roles": ["developer"]
}

A simplified permission check could look like:

def can_access(chunk, requester):
metadata = chunk["metadata"]
allowed_users = metadata.get("allowed_users", [])
allowed_groups = metadata.get("allowed_groups", [])
if requester["user_id"] in allowed_users:
return True
if set(requester["groups"]) & set(allowed_groups):
return True
return False

Then retrieval becomes:

results = vector_store.search(
query_embedding=query_embedding,
top_k=20
)
authorized_results = [
chunk
for chunk in results
if can_access(chunk, requester)
]
context = authorized_results[:5]

This is better than retrieving five chunks first and removing unauthorized results afterward.

Why?

Imagine the top five similarity results are all restricted.

After filtering, your application could have no context left.

Retrieving a wider candidate set and applying authorization before final ranking helps avoid that failure mode.

Even better, when your vector database supports metadata filtering, push appropriate permission constraints into the query itself.

Conceptually:

vector_store.search(
query_embedding=query_embedding,
filter={
"tenant_id": requester["tenant_id"]
}
)

Then perform any more complex authorization checks in the application layer.

This creates defense in depth:

User
↓
Authentication
↓
Build requester security context
↓
Vector metadata filtering
↓
Application authorization check
↓
Ranking
↓
LLM context

That boundary is a core part of RAG security.

Don't Treat Prompt Instructions as Authorization

This is not a permission control:

SYSTEM:
Only reveal documents that the user is authorized to access.

The model should not be responsible for deciding whether retrieved data was authorized.

If confidential content has already entered the model context, the security boundary has already been crossed.

Prompt instructions can influence generation behavior.

They should not replace deterministic access-control logic.

When designing secure AI systems, authorization should happen before protected information enters the prompt.

Test Deliberately Mismatched Permission Scenarios

Permission logic deserves dedicated security tests.

Do not test only successful retrieval.

Create users who should specifically not receive certain documents.

For example:

def test_engineer_cannot_access_finance_document():
requester = {
"user_id": "alice",
"groups": ["engineering"]
}
chunk = {
"metadata": {
"allowed_groups": ["finance-team"],
"allowed_users": []
}
}
assert can_access(chunk, requester) is False

Then test the complete retrieval pipeline.

`def test_retrieval_does_not_return_restricted_chunks():

results = retrieve(
    query="What is the acquisition budget?",
    requester=engineering_user
)
restricted_ids = {
    "finance-q4-plan-001",
    "finance-q4-plan-002"
}
returned_ids = {r["id"] for r in results}
assert returned_ids.isdisjoint(restricted_ids)`
Enter fullscreen mode Exit fullscreen mode

Useful negative-test scenarios include:

  • Same tenant, wrong department
  • Correct role, wrong tenant
  • Former group member
  • Public and confidential chunks matching the same query
  • Permission changed after initial indexing
  • Document access revoked after embeddings were created
  • User explicitly asking the model to reveal restricted documents

The goal is not merely proving that authorized retrieval works.

It is proving that unauthorized retrieval fails predictably.

Watch for Permission Drift

There is another subtle source of data leakage: permissions can change after indexing.

Suppose a document was originally available to everyone in engineering.

Later, access is restricted to three managers.

If your vector index still contains the original metadata, the RAG system may continue serving stale permissions.

You therefore need a strategy for permission synchronization.

Possible approaches include:

ACL change
↓
Event / scheduled synchronization
↓
Locate document chunks
↓
Update metadata or remove vectors

For higher-risk environments, permission checks can also reference a live authorization service rather than trusting indexed ACL metadata alone.

That introduces latency and complexity, but it reduces dependence on potentially stale authorization data.

Choosing between these approaches is an architectural decision based on security requirements, scale, and how frequently permissions change.

RAG Permission Enforcement Is Part of the Retrieval Layer

A secure RAG pipeline should not think of retrieval as:

Query → Similarity Search → LLM

It should look more like:

Query
↓
Authenticate requester
↓
Resolve identity + roles + groups
↓
Retrieve candidate chunks
↓
Enforce document permissions
↓
Rank authorized chunks
↓
Build context
↓
LLM

That additional authorization layer matters because vector similarity answers:

"Which chunks are relevant?"

It does not answer:

"Which relevant chunks is this user allowed to see?"

Those are different problems.

For teams building production RAG applications, understanding patterns like metadata authorization, identity-aware retrieval, permission synchronization, and adversarial testing is increasingly important. Structured professional AI security training can also help engineering and security teams treat these controls as part of the AI application architecture rather than an afterthought.

The core rule is simple: preserve authorization metadata during ingestion, enforce permissions before generation, and test the cases where retrieval should fail.

Your embeddings may understand meaning.

They do not understand permission boundaries unless you explicitly build those boundaries into the pipeline.

Top comments (0)