01 Understanding the Problem: Can AI Read Every Document?
In the previous lesson, we learned how vector databases perform high-speed similarity search across millions of embedded vectors. However, when deploying an enterprise multi-tenant SaaS application, a critical security dilemma arises: "Can the AI assistant disclose any matching document to any user?"
Imagine your Byteion SaaS Platform hosts three distinct enterprise clients (tenants):
Byteion Multi-Tenant Enterprise SaaS
│
├── Tenant A: Alpha Corp (Enterprise Client)
│ ├── Employee Handbook (Public to Tenant A)
│ ├── HR Leave Policy (Public to Tenant A)
│ └── Executive Finance Audit 2026 (Restricted: Finance Managers Only)
│
├── Tenant B: FinTech Global (Financial Services)
│ ├── Employee Handbook (Public to Tenant B)
│ ├── Regulatory Compliance Guide (Compliance Team Only)
│ └── Customer Financial Ledger (Confidential: C-Level Only)
│
└── Tenant C: Demo Sandbox (Trial Organization)
└── Public Documentation & Help Guides
A junior software engineer from Tenant A queries the AI assistant: "What is our annual leave entitlement and executive bonus distribution policy?"
If your retrieval pipeline executes unconstrained vector similarity search across all points in the index, two catastrophic security breaches occur:
If Tenant B's confidential salary or bonus structure chunk happens to have higher cosine similarity to the query, Tenant A's user will receive confidential proprietary data belonging to a competitor!
Even within Tenant A, an entry-level developer gains unauthorized access to C-level executive payroll spreadsheets and board meeting minutes.
The Fundamental Conflict of Two Questions:
Question 1 (Relevance): Which chunk is semantically closest to the user's inquiry? (Governed by Vector Distance / Cosine Similarity).
Question 2 (Authorization): Does the authenticated user possess the legal and operational rights to inspect this chunk? (Governed by Access Control Policies).
Permission-Aware Retrieval is an architectural paradigm where user identity and authorization credentials strictly gate the search space so that vector similarity search evaluates only documents the user is explicitly permitted to read.
02 What is Metadata Filtering & How Does It Work?
Metadata refers to structured attributes attached to every chunk ingested into the vector database. For example:
{
"chunk_id": "chk_hr_9921",
"document_id": "doc_leave_001",
"tenant_id": "tenant_alpha",
"source": "employee-leave-policy-2026.pdf",
"page_number": 4,
"category": "hr_policy",
"visibility": "tenant",
"document_version": "v2"
}
Metadata Filtering is the process of applying strict logical predicates (constraints) over these structured attributes before or during vector search to restrict candidate points to an authorized, relevant subset:
[ Total Knowledge Base: 10,000,000 Chunks across 500 Tenants ]
│
▼ Predicate: tenant_id == "tenant_alpha"
[ Tenant Isolation Space: 20,000 Chunks ]
│
▼ Predicate: visibility == "tenant" OR role IN user_roles
[ Authorized Subgraph: 3,500 Chunks ]
│
▼ Vector Similarity Search (HNSW Top-K ANN)
[ Top-5 Authorized & Relevant Chunks fed into LLM Context ]
Without metadata filtering, similarity search traverses the entire 10-million-point index indiscriminately. With metadata filtering, non-permitted clusters are completely culled from evaluation.
03 Authentication, Authorization vs. Retrieval
To design secure RAG systems, software engineers must maintain rigorous separation of concerns between three distinct architectural layers:
| Layer | Core Question | Mechanism | RAG Vulnerability if Breached |
|---|---|---|---|
| 1. Authentication (AuthN) | "Who are you?" | JWT, OAuth 2.0, SAML, Session Cookie | Impersonation, unauthenticated API calls. |
| 2. Authorization (AuthZ) | "What resources can you read?" | RBAC, ABAC, ACL, Policy Engine (OPA) | Horizontal/Vertical privilege escalation. |
| 3. Retrieval (Vector Search) | "What information matches the query?" | HNSW Index, Cosine, Dot Product | Hallucination, irrelevant context, data leakage. |
04 Access Control Models: RBAC, ABAC & ACL
Enterprise document stores typically employ one or a combination of three industry-standard access control models:
Permissions are assigned to roles (e.g., finance_manager, hr_lead, engineer). Users inherit rights by belonging to one or more roles.
Access depends on contextual attributes: department, security clearance level, geographic region, time of day, or device compliance.
Direct list of explicit user identifiers permitted to access a specific document (e.g., Google Drive share list or personal contracts).
05 Designing Document Access Metadata Schema
To support hybrid access rules (combining tenant isolation, organizational roles, and explicit user lists), we define a production Pydantic metadata schema:
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field
class DocumentVisibility(str, Enum):
PUBLIC = "public" # Accessible across the entire tenant
RESTRICTED = "restricted" # Limited to specific roles or users
CONFIDENTIAL = "confidential" # Strict C-Level or executive only
class ChunkSecurityPayload(BaseModel):
chunk_id: str
document_id: str
tenant_id: str = Field(..., description="Mandatory tenant boundary identifier")
# Access Control Matrix
visibility: DocumentVisibility = DocumentVisibility.RESTRICTED
allowed_roles: List[str] = Field(default_factory=list)
allowed_user_ids: List[str] = Field(default_factory=list)
# Classification Metadata
department: str
category: str
security_level: int = Field(default=1, ge=1, le=5) # 1: Low, 5: Secret
# Provenance and Versioning
source_filename: str
page_number: int
acl_version: int = 1
Every chunk payload stored in Qdrant or pgvector carries these exact attributes, ensuring that every vector embedding is bound to its security context.
06 Permission Propagation: Inherit from Documents, Not Raw Chunks
When a 50-page confidential PDF is ingested, users do not assign permissions to individual 500-token chunks. Permissions are managed at the Document Level or Folder/Workspace Level.
The Permission Propagation Pipeline
This strict inheritance ensures that no single orphan chunk is ever stored without an explicit, verifiable tenant ID and role boundary.
07 Security Payload Indexes & Tenant Optimization in Qdrant
By default, vector databases index high-dimensional vectors, not payload attributes. If you query millions of vectors with filter={"tenant_id": "tenant_alpha"} without creating an index on the payload field, Qdrant is forced to perform a full sequential scan of payload attributes across the entire disk!
In Qdrant, we must explicitly create Payload Indexes on high-cardinality security attributes:
from qdrant_client import QdrantClient
from qdrant_client.http import models
client = QdrantClient(url="http://localhost:6333")
COLLECTION_NAME = "enterprise_knowledge"
# 1. Critical: Tenant ID Index (Must be KEYWORD)
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="tenant_id",
field_schema=models.PayloadSchemaType.KEYWORD,
is_tenant=True # Enables Qdrant multi-tenant memory optimizations!
)
# 2. Roles Index (For fast array membership lookup)
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="allowed_roles",
field_schema=models.PayloadSchemaType.KEYWORD
)
# 3. User IDs Index
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="allowed_user_ids",
field_schema=models.PayloadSchemaType.KEYWORD
)
# 4. Document Visibility Index
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="visibility",
field_schema=models.PayloadSchemaType.KEYWORD
)
is_tenant=True): Setting is_tenant=True informs Qdrant that tenant_id partitions the collection. Qdrant will store points belonging to the same tenant in contiguous memory spaces, speeding up filtered HNSW traversal by up to 10x while maintaining strict physical co-location.
08 Enforcing Permissions with Qdrant Filters: must vs. should
Qdrant's filter model mirrors Boolean logic:
must: Every condition inside must evaluate to TRUE (LogicalAND).should: At least one condition inside must evaluate to TRUE (LogicalOR).must_not: Conditions inside must evaluate to FALSE (LogicalNOT).
The universal formula for multi-tenant permission retrieval is:
AND
SHOULD (
visibility == "public"
OR current_user.id IN allowed_user_ids
OR any(current_user.roles) IN allowed_roles
)
Here is the Python implementation using Qdrant HTTP Models:
from typing import Set
from qdrant_client.http import models
def build_qdrant_permission_filter(
tenant_id: str,
user_id: str,
roles: Set[str]
) -> models.Filter:
"""
Constructs an airtight boolean filter ensuring:
1. Zero cross-tenant leakage (must tenant_id)
2. Precise RBAC & ACL enforcement (should clauses)
"""
# Clause 1: Hard Tenant Isolation (Non-negotiable)
tenant_constraint = models.FieldCondition(
key="tenant_id",
match=models.MatchValue(value=tenant_id)
)
# Clause 2: Permission Matrix (Public OR Explicit User OR Matching Role)
permission_clauses = [
# Accessible if marked tenant-public
models.FieldCondition(
key="visibility",
match=models.MatchValue(value="tenant")
),
# Accessible if user is explicitly named in ACL
models.FieldCondition(
key="allowed_user_ids",
match=models.MatchValue(value=user_id)
)
]
# Accessible if any of the user's active roles match
for role in roles:
permission_clauses.append(
models.FieldCondition(
key="allowed_roles",
match=models.MatchValue(value=role)
)
)
return models.Filter(
must=[tenant_constraint],
should=permission_clauses
)
09 Executing Vector Search with Permission Filters
Now we integrate the permission filter directly into the vector search call. Notice how Qdrant receives the filter as a first-class search parameter:
from typing import List, Dict, Any, Set
from qdrant_client import QdrantClient
def search_authorized_knowledge(
qdrant: QdrantClient,
query_vector: List[float],
tenant_id: str,
user_id: str,
roles: Set[str],
limit: int = 5
) -> List[Dict[str, Any]]:
# 1. Build the permission filter
security_filter = build_qdrant_permission_filter(
tenant_id=tenant_id,
user_id=user_id,
roles=roles
)
# 2. Execute Pre-Filtered Vector Search
search_results = qdrant.search(
collection_name="enterprise_knowledge",
query_vector=query_vector,
query_filter=security_filter,
limit=limit,
with_payload=True
)
# 3. Format safe payload results
authorized_chunks = []
for hit in search_results:
payload = hit.payload
authorized_chunks.append({
"chunk_id": payload.get("chunk_id"),
"document_id": payload.get("document_id"),
"tenant_id": payload.get("tenant_id"),
"score": hit.score,
"text": payload.get("text"),
"source": payload.get("source_filename"),
"category": payload.get("category")
})
return authorized_chunks
10 Authenticated User Context in FastAPI Backend
In a production backend, clients must never supply tenant_id or roles in the HTTP request body. All security claims must be extracted exclusively from cryptographically verified server-side session credentials or signed JWT tokens:
from dataclasses import dataclass
from fastapi import FastAPI, Depends, Header
from pydantic import BaseModel, Field
app = FastAPI()
@dataclass(frozen=True)
class AuthenticatedPrincipal:
user_id: str
tenant_id: str
roles: frozenset[str]
class KnowledgeSearchRequest(BaseModel):
query: str = Field(..., min_length=2, max_length=1000)
limit: int = Field(default=5, ge=1, le=20)
# Notice: Zero tenant_id or role fields in request body!
# Clients cannot forge or elevate their authorization context.
async def authenticate_request(
authorization: str = Header(..., description="Bearer JWT Token")
) -> AuthenticatedPrincipal:
"""Verifies cryptographic signature and decodes trusted principal identity."""
token = authorization.replace("Bearer ", "").strip()
payload = verify_and_decode_jwt(token) # Internal JWT validation function
return AuthenticatedPrincipal(
user_id=payload["sub"],
tenant_id=payload["tenant_id"],
roles=frozenset(payload.get("roles", []))
)
@app.post("/api/v1/knowledge/search")
async def secure_knowledge_search(
req: KnowledgeSearchRequest,
principal: AuthenticatedPrincipal = Depends(authenticate_request)
):
# Only trusted principal credentials gate the search
results = search_authorized_knowledge(
qdrant=qdrant_client,
query_vector=generate_embedding(req.query),
tenant_id=principal.tenant_id,
user_id=principal.user_id,
roles=set(principal.roles),
limit=req.limit
)
return {"query": req.query, "results": results}
11 Pre-Filtering vs. Post-Filtering: Defense in Depth
A common rookie mistake is Post-Filtering: retrieving the Top-10 vectors unconditionally, and then pruning unauthorized results in Python code. This leads to critical vulnerabilities and severe Result Starvation.
Interactive Security Lab: Multi-Tenant & Access Control Studio
Authorized Chunks Passed to LLM Context:
- Vector search runs across entire DB and requests Top 5 chunks.
- 3 chunks belong to other tenants; 1 chunk requires executive role.
- Python code filters out unauthorized chunks.
- Result Starvation: User only receives 1 chunk, or 0 chunks!
- LLM responds: "I don't know" despite valid documents existing.
- Permission filter is applied directly to the Qdrant graph before traversal.
- HNSW exploration is restricted strictly to permitted candidates.
- Full Budget Utilization: User receives a complete set of 5 relevant, authorized chunks.
- Zero data leakage and maximum generation quality.
Role Revocation & Stale ACL Scenario
Bob previously served as hr_manager. At 12:00 UTC, his permissions were downgraded to ordinary employee.
Roles: ["employee"]
Updated: 12:00:01 UTC
Status: STALE (Out of Sync!)
acl_version during document metadata synchronization.
12 PostgreSQL Row-Level Security (RLS) for Secondary Defense
If you store embeddings in PostgreSQL using the pgvector extension, you can leverage PostgreSQL's native Row-Level Security (RLS) engine. Even if your backend application code contains a software bug in filter construction, the database kernel will mathematically refuse to return rows belonging to another tenant!
-- 1. Enable Row-Level Security on document chunks table
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
-- 2. Define Tenant Isolation Policy
-- Reads are permitted ONLY if the row's tenant_id matches current session variable
CREATE POLICY tenant_isolation_policy ON document_chunks
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true));
-- 3. Application Connection Setup in Python/SQLAlchemy
-- When acquiring a connection from the pool, set session context:
-- SET LOCAL app.current_tenant_id = 'tenant_alpha';
-- Now any SELECT * automatically isolates Tenant Alpha data!
PostgreSQL RLS provides true defense-in-depth: security is enforced at the database kernel level rather than relying solely on application-level where-clauses.
13 Handling Stale ACLs & Permission Invalidation
Permissions are not static. Employees resign, roles change, and documents are demoted from public to confidential. If your vector database contains cached permissions that are out of sync with your primary database, you have Stale ACLs.
When a user's role changes, invalidate their active JWT and session cache immediately. When they query the AI, the backend loads fresh roles from PostgreSQL or Auth0 and constructs the updated filter.
When a document's visibility is modified, broadcast an event via Kafka/Celery to execute qdrant.set_payload() matching document_id to update chunk permissions without recomputing expensive embeddings.
14 LLM Context Defense & Preventing Indirect Prompt Injection
Even when permission-aware filtering succeeds, an attacker might upload a document containing an Indirect Prompt Injection payload:
To prevent the LLM from obeying instructions inside retrieved documents:
- Enclose Context in Strict XML Delimiters: Wrap all retrieved context chunks in
<context>...</context>tags. - Explicit System Prompt Rules: Instruct the model that text within
<context>is strictly untrusted data, not operational instructions. - Structural Citation Isolation: Return document metadata (title, page number, ID) separately from the generated answer.
15 Writing Automated Pytest Security Suites for Permission-Aware Retrieval
Never ship a multi-tenant retrieval system without automated security integration tests. Here is an essential Pytest suite:
import pytest
def test_cross_tenant_isolation(qdrant_mock):
"""Tenant A's user must NEVER receive chunks belonging to Tenant B."""
results = search_authorized_knowledge(
qdrant=qdrant_mock,
query_vector=[0.1] * 1536,
tenant_id="tenant_alpha",
user_id="alice",
roles={"employee"}
)
for r in results:
assert r["tenant_id"] == "tenant_alpha"
assert r["tenant_id"] != "tenant_beta"
def test_confidential_document_blocked_for_normal_employee(qdrant_mock):
"""Ordinary employees must never retrieve confidential finance audit chunks."""
results = search_authorized_knowledge(
qdrant=qdrant_mock,
query_vector=[0.1] * 1536,
tenant_id="tenant_alpha",
user_id="alice",
roles={"employee"}
)
categories = [r["category"] for r in results]
assert "finance_audit" not in categories
def test_finance_manager_can_access_audit_report(qdrant_mock):
"""Authorized finance manager must successfully retrieve audit chunks."""
results = search_authorized_knowledge(
qdrant=qdrant_mock,
query_vector=[0.1] * 1536,
tenant_id="tenant_alpha",
user_id="charlie",
roles={"employee", "finance_manager"}
)
categories = [r["category"] for r in results]
assert "finance_audit" in categories
16 Practical Project: Secure Multi-Tenant RAG Retrieval Service
We now consolidate all concepts into a battle-tested enterprise retrieval pipeline.
12-Point Production Security Audit Checklist:
must filter on tenant_idshould clausesis_tenant=True memory optimization17 Knowledge Check: Test Your Understanding
Validate your mastery of enterprise multi-tenant RAG security and metadata filtering:
What is the primary objective of Metadata Filtering in enterprise RAG?
Explanation: Metadata filtering prunes the search graph to guarantee tenant isolation and authorization compliance.
Where should the tenant ID originate from when executing an authorized search?
Explanation: Never trust client-supplied tenant identifiers; always resolve tenant identity from cryptographically validated credentials.
Why is creating a KEYWORD payload index on security fields essential in Qdrant?
Explanation: Payload indexes allow Qdrant to perform instantaneous boolean filtering without sequential disk scans.
In Qdrant boolean filtering, what is the role of must vs. should in access control?
must enforces non-negotiable tenant boundaries (AND), while should handles role/user membership (OR).
✓ Correct
must is optional, while should is required.
Explanation: Tenant identity is mandatory (must), while individual roles and ACL entries are alternatives (should).
Why is Post-Filtering (filtering in application code after vector search) considered dangerous and defective?
Explanation: Pre-filtering guarantees that 100% of the returned Top-K budget consists of valid, authorized candidates.
What danger arises when a vector database contains Stale ACLs after an employee's role is revoked?
Explanation: Dynamic role injection from the authoritative auth provider prevents stale credential exploitation.
What is the primary advantage of PostgreSQL Row-Level Security (RLS)?
Explanation: RLS acts as a kernel-level safety net beneath the application layer.
How should an AI system handle indirect prompt injections discovered inside retrieved documents?
Explanation: System prompts must explicitly demarcate context as untrusted data to nullify injected instructions.
18 Summary & What's Next
Permission-aware retrieval transforms vector search from a fragile demo into an enterprise-ready knowledge platform. In this lesson, we established:
- Tenant Boundaries: Non-negotiable
mustfilter conditions seeded exclusively from cryptographic server tokens. - Access Models: Combining RBAC, ABAC, and ACLs into unified boolean filter payloads.
- Pre-Filtering: Performing candidate restriction during vector graph traversal to avoid result starvation.
- Defense in Depth: Coupling Qdrant indexes with PostgreSQL RLS and strict prompt boundaries.
Lesson 16.17: Keyword Search, Hybrid Retrieval & Reciprocal Rank Fusion
BM25 algorithm, dense vs. sparse embeddings, and unifying lexical and semantic search engines using Reciprocal Rank Fusion (RRF).