RAG Document Permissions: Access Control in Retrieval
TL;DR — Enterprise RAG systems that enforce access control in the application layer leak confidential documents. The model processes unauthorized documents before the filter catches them. Access control must live in the retrieval layer — at the vector database itself. Attach permission metadata (tenant_id, acl_groups, sensitivity) to every vector. Inject the user's permissions as pre-filters on every vector search. Use permission-aware chunking so chunks never cross access boundaries. Keep vector metadata in sync with source system permission changes — embeddings don't know the contractor was offboarded. Use hybrid retrieval (BM25 + dense embeddings) for enterprise queries that are often entity-specific. Never retrieve what the user can't see; never generate from what wasn't retrieved.
The Core Assumption That Breaks Everything
Public-web RAG works on a simple model: index everything, retrieve by semantic similarity, generate from what you find. This works because the corpus is homogeneous and access-unrestricted — anyone can read what anyone else retrieves.
Enterprise knowledge breaks both properties. A typical organization has Confluence pages, Slack messages, Google Drive files, CRM records, org charts, and engineering wikis — each with its own permission model. A support engineer can read customer tickets but not salary bands. A junior analyst can read public dashboards but not board materials.
When you index all of this into a vector store and query by similarity, you merge these permission models into a single retrieval surface. Without additional controls, the retrieval layer becomes a privilege escalation vector: a user with limited access can trigger retrieval of documents they couldn't directly open, and the model will synthesize those documents into a response as if they were fair game.
Why Application-Layer Filtering Fails
The straightforward fix — "filter results in application code before showing them to the user" — is where most teams stop. It's insufficient for three reasons:
-
The model has already processed the document. If the application layer catches the unauthorized retrieval only after generation, the model has already synthesized the content into a response. The damage is done.
-
Application-layer filters are easy to bypass. Prompt injection, API misuse, or simple bugs can cause the filter to be skipped or circumvented.
-
Information leakage through result count. Even if the filter removes unauthorized documents before displaying results, the user can infer the existence of documents from result counts, timing, or error messages.
The Fix: Access Control in the Retrieval Layer
Access control must be enforced at the vector database, before similarity computation returns results. The database should only return vectors that match both the similarity query and the user's permission filter.
User Query → Permission Filter → Vector Search → Authorized Results → LLM Generation
↑
This must happen at the database,
not in application code
Permission Metadata on Vectors
Every vector in the database must carry permission metadata:
{
"id": "vec-001",
"values": [0.1, 0.2, 0.3, ...],
"metadata": {
"document_id": "doc-789",
"chunk_id": "chunk-3",
"tenant_id": "tenant-456",
"acl_groups": ["engineering", "leadership"],
"acl_users": ["alice@company.com", "bob@company.com"],
"sensitivity": "internal",
"source_system": "confluence",
"source_url": "https://company.atlassian.net/wiki/spaces/ENG/pages/789",
"ingested_at": "2026-07-01T10:00:00Z",
"permissions_version": 3
}
}
Permission Models
| Model | Description | Metadata Fields |
|---|---|---|
| Tenant isolation | User belongs to a tenant; only see tenant's docs | tenant_id |
| Group-based ACL | User belongs to groups; doc accessible to groups | acl_groups |
| User-based ACL | Specific users have access | acl_users |
| Sensitivity tier | User has a clearance level; doc has a sensitivity | sensitivity |
| Document-level ACL | Each doc has an explicit ACL from the source system | acl_groups + acl_users |
Most enterprises need a combination: tenant isolation + group-based ACL + sensitivity tiers.
Enforcing Permissions at Query Time
Pinecone: Metadata Filtering
results = index.query(
vector=query_embedding,
top_k=10,
namespace="tenant-456", # Tenant isolation
filter={
"acl_groups": {"$in": user.groups}, # Group-based ACL
"sensitivity": {"$lte": user.clearance_level} # Sensitivity tier
}
)
# Only returns vectors matching both similarity and permission filter
pgvector: Row-Level Security
-- Enable RLS on embeddings table
ALTER TABLE embeddings ENABLE ROW LEVEL SECURITY;
-- Policy: user can only see rows where their groups intersect acl_groups
CREATE POLICY rag_access_control ON embeddings
USING (
tenant_id = current_setting('app.tenant_id')
AND acl_groups && string_to_array(current_setting('app.user_groups'), ',')
AND sensitivity <= current_setting('app.clearance_level')::int
);
-- Set session context
SET app.tenant_id = 'tenant-456';
SET app.user_groups = 'engineering,leadership';
SET app.clearance_level = '2';
-- Vector search automatically filtered by RLS
SELECT document_id, chunk_text,
embedding <=> '[0.1, 0.2, ...]' AS distance
FROM embeddings
ORDER BY distance
LIMIT 10;
Weaviate: Tenant Shards + Metadata Filtering
# Query within tenant's shard
tenant_collection = client.collections.get("Document").with_tenant("tenant-456")
results = tenant_collection.query.near_vector(
query_vector=query_embedding,
limit=10,
filters=Filter(
operator=Operator.And,
operands=[
Filter(path=["acl_groups"], operator=Operator.ContainsAny, valueText=user.groups),
Filter(path=["sensitivity"], operator=Operator.LessThanEqual, valueInt=user.clearance_level)
]
)
)
Permission-Aware Chunking
Chunking strategy affects permission enforcement. If a chunk crosses a permission boundary, it leaks content from both sides to anyone who can access either side.
The Problem
Consider a document with two sections:
- Section 1 (public): "Q3 Financial Summary — Revenue grew 15% YoY"
- Section 2 (restricted): "Q3 Financial Details — Salary bands: Engineer $120-180k, Director $200-350k"
If chunking merges text from both sections into one chunk, and that chunk is tagged with the public section's permissions, a user with public access can retrieve the chunk and see salary bands.
The Solution
Chunk at permission boundaries. Each chunk inherits the most restrictive permission of any text it contains:
def permission_aware_chunk(document: Document) -> list[Chunk]:
chunks = []
for section in document.sections:
# Chunk within each section separately
section_chunks = text_splitter.split_text(section.content)
for chunk_text in section_chunks:
chunks.append(Chunk(
text=chunk_text,
document_id=document.id,
acl_groups=section.acl_groups, # Section's permissions
acl_users=section.acl_users,
sensitivity=section.sensitivity,
tenant_id=document.tenant_id
))
return chunks
Handling Documents with Mixed Permissions
Some documents have permissions that vary by section:
| Section | Content | ACL |
|---|---|---|
| Executive Summary | Public overview | ["all_employees"] |
| Financial Details | Revenue, costs, margins | ["finance", "leadership"] |
| Salary Bands | Compensation ranges | ["hr", "leadership"] |
| Appendix | Raw data tables | ["finance"] |
Each section is chunked independently with its own ACL metadata. A user in all_employees can retrieve the executive summary chunks but not the financial details or salary band chunks.
The Permission Sync Problem
Embeddings don't know the contractor was offboarded. When permissions change in the source system, the vector database must be updated to reflect those changes.
Permission Change Events
class PermissionSyncHandler:
def handle_permission_change(self, event: PermissionChangeEvent):
if event.type == "user_offboarded":
# Remove user from all ACL metadata
self.vector_db.update_metadata(
filter={"acl_users": {"$in": [event.user_id]}},
update={"$pull": {"acl_users": event.user_id}}
)
elif event.type == "group_membership_changed":
if event.action == "added":
# Add group to user's accessible docs
pass # No vector update needed — query-time filter handles this
elif event.action == "removed":
# Remove group from docs that were only accessible via this group
# This is harder — need to check if user has other paths to the doc
pass
elif event.type == "document_permission_changed":
# Update all chunks of this document
self.vector_db.update_metadata(
filter={"document_id": event.document_id},
update={
"acl_groups": event.new_acl_groups,
"acl_users": event.new_acl_users,
"sensitivity": event.new_sensitivity
}
)
elif event.type == "document_deleted":
# Delete all vectors for this document
self.vector_db.delete(filter={"document_id": event.document_id})
Sync Strategies
| Strategy | Description | Latency | Complexity |
|---|---|---|---|
| Event-driven | Listen to source system webhooks, update vectors in real-time | Seconds | High |
| Polling | Periodically poll source system for permission changes | Minutes | Medium |
| Full re-sync | Re-ingest all documents with fresh permissions periodically | Hours | Low |
| Lazy evaluation | Check permissions at query time against the source system | Real-time | High latency |
Recommendation: Event-driven for permission removals (security-critical, must be fast). Polling for permission additions (less urgent). Full re-sync as a safety net.
The Stale Permission Window
Between a permission change in the source system and the vector database update, there is a window where the vector database has stale permissions. During this window:
- A removed user might still retrieve documents they no longer have access to
- A newly added user might not yet see documents they should have access to
Mitigation: For security-critical removals, use query-time permission verification as a secondary check:
def retrieve_with_verification(query: str, user: User) -> list[Chunk]:
# Step 1: Vector search with stored metadata filter
candidates = vector_db.search(query, filter=user.permission_filter)
# Step 2: Verify each result against source system (defense in depth)
verified = []
for chunk in candidates:
if source_system.check_access(user, chunk.document_id):
verified.append(chunk)
else:
# Stale metadata detected — trigger sync for this document
sync_handler.sync_document(chunk.document_id)
return verified
Hybrid Retrieval for Enterprise
Enterprise queries are often entity-specific: "find the Q3 sales pipeline review for the EMEA team." These are lookups against proper nouns, identifiers, and exact phrases that embedding models handle poorly — internal jargon, product codes, and team names are statistically rare in public training data.
BM25 + Dense Embeddings
def hybrid_retrieve(query: str, user: User, top_k: int = 10) -> list[Chunk]:
# BM25 (lexical) — excels at entity-specific queries
bm25_results = bm25_search(
query=query,
filter=user.permission_filter,
top_k=top_k * 2
)
# Dense embeddings (semantic) — excels at conceptual queries
dense_results = vector_db.search(
vector=embed(query),
filter=user.permission_filter,
top_k=top_k * 2
)
# Reciprocal Rank Fusion
fused = reciprocal_rank_fusion(bm25_results, dense_results)
return fused[:top_k]
def reciprocal_rank_fusion(bm25_results, dense_results, k=60):
scores = {}
for rank, result in enumerate(bm25_results):
scores[result.id] = scores.get(result.id, 0) + 1 / (k + rank + 1)
for rank, result in enumerate(dense_results):
scores[result.id] = scores.get(result.id, 0) + 1 / (k + rank + 1)
# Sort by fused score
all_results = {r.id: r for r in bm25_results + dense_results}
sorted_ids = sorted(scores, key=scores.get, reverse=True)
return [all_results[id] for id in sorted_ids]
Permission filter on both: Both BM25 and dense search must apply the same permission filter. A common mistake is applying permissions only to the vector search but not the BM25 search, leaking unauthorized documents through the lexical path.
Production Architecture
Source Systems (Confluence, Drive, Slack, CRM)
↓ (permission-aware ingestion)
Ingestion Pipeline
├── Redact PII before embedding
├── Permission-aware chunking (chunk at permission boundaries)
├── Attach permission metadata to each chunk
└── Write to vector database with metadata
↓
Vector Database (permission-filtered retrieval)
├── Dense embeddings (semantic search)
├── BM25 index (lexical search)
└── Permission metadata on every vector
↓
Query Time
├── User permissions → pre-filter on vector search
├── Hybrid retrieval (BM25 + dense, both permission-filtered)
├── Reciprocal Rank Fusion
└── Source system verification (defense in depth)
↓
LLM Generation
└── Generate only from authorized, retrieved context
Per-Tenant Configuration
tenant_rag_permissions:
tenant_id: "tenant-456"
permission_model:
type: "group_acl + sensitivity_tier"
groups_source: "okta"
sensitivity_levels: [1, 2, 3, 4] # 1=public, 4=restricted
chunking:
permission_aware: true
chunk_at_permission_boundaries: true
retrieval:
hybrid: true
bm25_weight: 0.4
dense_weight: 0.6
permission_filter_on_both: true
sync:
strategy: "event_driven"
webhook_endpoint: "/webhooks/permission-changes"
stale_permission_window_seconds: 300
full_resync_interval_hours: 24
defense_in_depth:
source_system_verification: true
verify_on_retrieval: true
audit:
log_retrieved_documents: true
log_permission_denials: true
log_stale_metadata_events: true
Common Mistakes
Filtering After Generation
Retrieving all documents by similarity, generating a response, then filtering the response. The model has already seen and used the unauthorized content.
Fix: Filter at the vector database before retrieval. Never retrieve what the user can't see.
Permission Metadata on Documents, Not Chunks
Storing permissions at the document level but not propagating to chunks. A chunk from a restricted section is retrievable because the chunk doesn't carry the document's permission metadata.
Fix: Every chunk inherits the most restrictive permission from its source section.
Not Syncing Permission Removals
A contractor is offboarded, but their user_id remains in acl_users metadata on vectors. They can still retrieve documents until the next full re-sync.
Fix: Event-driven sync for permission removals. Query-time verification as defense in depth.
BM25 Without Permission Filters
Applying permission filters only to the vector search but not the BM25 search. Unauthorized documents leak through the lexical retrieval path.
Fix: Apply the same permission filter to both retrieval paths.
Shared Index Without Tenant Filtering
Multiple tenants in the same vector index without tenant_id metadata filtering. A query from tenant A returns documents from tenant B.
Fix: Every vector carries tenant_id. Every query includes tenant_id in the filter. Use separate namespaces (Pinecone) or shards (Weaviate) where possible.
Implementation Checklist
- [ ] Attach permission metadata to every vector (tenant_id, acl_groups, acl_users, sensitivity)
- [ ] Enforce access control at the vector database, not the application layer
- [ ] Use permission-aware chunking — chunk at permission boundaries
- [ ] Each chunk inherits the most restrictive permission of its source section
- [ ] Implement metadata filtering at query time (Pinecone filter, pgvector RLS, Weaviate filters)
- [ ] Use hybrid retrieval (BM25 + dense embeddings) with permission filters on both paths
- [ ] Implement event-driven permission sync for removals (webhooks from source systems)
- [ ] Implement polling or full re-sync as a safety net
- [ ] Add query-time source system verification as defense in depth
- [ ] Log all retrieved documents with user and permission context
- [ ] Log permission denials and stale metadata events
- [ ] Handle document deletion — delete all vectors for the document
- [ ] Handle user offboarding — remove from all acl_users metadata
- [ ] Handle group membership changes — update affected vectors
- [ ] Never retrieve what the user can't see — filter before similarity computation
- [ ] Test with adversarial queries (user trying to access unauthorized documents)
- [ ] Test permission sync latency — measure time from source change to vector update
- [ ] Test stale permission window — verify defense-in-depth catches stale metadata
- [ ] Configure per-tenant permission models and sync strategies
- [ ] Document the permission architecture for compliance audits
Conclusion
Enterprise RAG systems that enforce access control in the application layer leak confidential documents. The model processes unauthorized content before the filter catches it. The fix is simple in principle and rigorous in execution: access control must live in the retrieval layer.
Attach permission metadata to every vector. Filter at the vector database before similarity computation returns results. Use permission-aware chunking so chunks never cross access boundaries. Keep vector metadata in sync with source system permission changes — embeddings don't know the contractor was offboarded. Use hybrid retrieval with permission filters on both BM25 and dense search paths. Add query-time source system verification as defense in depth.
The pattern is: never retrieve what the user can't see, and never generate from what wasn't retrieved. If both invariants hold, the RAG system is permission-safe. If either breaks, the retrieval layer becomes a privilege escalation vector.