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:

  1. 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.

  2. Application-layer filters are easy to bypass. Prompt injection, API misuse, or simple bugs can cause the filter to be skipped or circumvented.

  3. 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.