Use when building SaaS applications needing data isolation between customers - implements owner-based filtering for secure multi-tenant document storage and search with workspace, organization, or tenant-level separation
Implements secure multi-tenant document storage for SaaS applications using owner-based filtering. Claude uses this when building customer-isolated systems, adding `owner_id` parameters to all operations for automatic database-level data isolation.
/plugin marketplace add juanre/llmemory/plugin install llmemory@juanre-ai-toolsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
uv add llmemory
# or
pip install llmemory
llmemory provides built-in multi-tenancy through the owner_id parameter. Every document operation is scoped to an owner, ensuring complete data isolation between tenants.
Key concepts:
owner_id: Top-level tenant identifier (workspace, organization, customer)id_at_origin: Secondary identifier within owner (user, thread, project)When to use this pattern:
from llmemory import LLMemory
async with LLMemory(connection_string="postgresql://localhost/mydb") as memory:
# Add document for workspace-1
await memory.add_document(
owner_id="workspace-1", # Tenant identifier
id_at_origin="user-123", # User within workspace
document_name="Q4 Report.pdf",
document_type=DocumentType.PDF,
content="..."
)
# Search is automatically filtered to workspace-1
results = await memory.search(
owner_id="workspace-1", # Only returns workspace-1 documents
query_text="quarterly revenue"
)
# workspace-2 cannot see workspace-1 data
results = await memory.search(
owner_id="workspace-2", # No results from workspace-1
query_text="quarterly revenue"
)
llmemory enforces multi-tenant isolation at the PostgreSQL database level, not just in application logic. Every operation that reads or modifies data includes SQL WHERE clauses that filter by owner_id, ensuring complete data isolation.
Key security guarantees:
Vector similarity search joins documents table and applies owner_id filtering:
# From src/llmemory/db.py:407
# Vector search SQL pattern:
SELECT
c.chunk_id,
c.document_id,
c.content,
c.metadata,
1 - (e.embedding <=> $1::vector) as similarity
FROM document_chunks c
JOIN documents d ON c.document_id = d.document_id
JOIN embeddings_table e ON c.chunk_id = e.chunk_id
WHERE d.owner_id = $2 -- PostgreSQL enforces this filter
ORDER BY e.embedding <=> $1::vector
LIMIT $3
The owner_id parameter is bound to $2 in the SQL query, making it impossible to retrieve documents belonging to other tenants even if the client attempts to manipulate the API.
Text search also enforces owner_id at the database level:
# From src/llmemory/manager.py:752-755
# Text search SQL pattern:
SELECT
c.chunk_id,
c.document_id,
c.content,
c.metadata,
ts_rank_cd(c.search_vector, websearch_to_tsquery('english', $1)) as rank
FROM document_chunks c
JOIN documents d ON c.document_id = d.document_id
WHERE c.search_vector @@ websearch_to_tsquery('english', $1)
AND d.owner_id = $2 -- Database-level tenant filtering
ORDER BY rank DESC
LIMIT $3
Hybrid search combines vector and text search, with both branches enforcing owner_id filtering:
# From src/llmemory/db.py (hybrid_search method)
# Both vector and text searches apply the same owner_id filter
# Results are then fused using Reciprocal Rank Fusion
# Vector branch filters:
WHERE d.owner_id = $2
# Text branch filters:
WHERE d.owner_id = $2
AND c.search_vector @@ websearch_to_tsquery('english', $1)
Document listing and statistics queries also filter by owner_id:
# List documents
SELECT * FROM documents
WHERE owner_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
# Count documents
SELECT COUNT(*) FROM documents
WHERE owner_id = $1
# Delete documents
DELETE FROM documents
WHERE document_id = ANY($1)
AND owner_id = $2 -- Prevents deleting other tenants' documents
PostgreSQL-enforced isolation:
documents table and filter on d.owner_id$2), not string-interpolatedAdditional safeguards:
document_id AND owner_id matchWhat this means:
owner_id="*" or SQL injection attempts, PostgreSQL parameterized queries prevent abuseRequired in all operations:
Every llmemory operation requires owner_id for multi-tenant isolation:
# Document operations
await memory.add_document(owner_id="...", ...)
await memory.list_documents(owner_id="...")
await memory.delete_documents(owner_id="...")
await memory.get_statistics(owner_id="...")
# Search operations
await memory.search(owner_id="...", ...)
await memory.search_with_documents(owner_id="...")
Validation:
^[a-zA-Z0-9_\-\.]+$Examples of valid owner_ids:
owner_id="workspace-123"
owner_id="org_abc_def"
owner_id="customer.456"
owner_id="tenant-uuid-here"
Secondary identifier within an owner (optional but recommended):
await memory.add_document(
owner_id="workspace-1", # Who owns this
id_at_origin="user-123", # Who created it within owner
document_name="report.pdf",
content="..."
)
# Search by origin
results = await memory.search(
owner_id="workspace-1",
query_text="report",
id_at_origin="user-123" # Filter to this user's documents
)
# Search across multiple origins
results = await memory.search(
owner_id="workspace-1",
query_text="report",
id_at_origins=["user-123", "user-456"] # Documents from either user
)
Common id_at_origin patterns:
"user-123", "auth0|abc""thread-789", "conversation-xyz""project-456""channel-general"All tenants in one database, filtered by owner_id:
# Single LLMemory instance serves all tenants
memory = LLMemory(connection_string="postgresql://localhost/shared_db")
await memory.initialize()
# Each request provides its owner_id
async def handle_search_request(tenant_id: str, query: str):
results = await memory.search(
owner_id=tenant_id, # Automatic filtering
query_text=query
)
return results
# tenant-1 request
results_1 = await handle_search_request("tenant-1", "query")
# tenant-2 request
results_2 = await handle_search_request("tenant-2", "query")
# Results are completely isolated by owner_id
Advantages:
Disadvantages:
Each tenant gets their own PostgreSQL schema:
from pgdbm import AsyncDatabaseManager, DatabaseConfig
# Create shared pool
config = DatabaseConfig(connection_string="postgresql://localhost/mydb")
shared_pool = await AsyncDatabaseManager.create_shared_pool(config)
# Create LLMemory instance per tenant with schema isolation
async def get_memory_for_tenant(tenant_id: str) -> LLMemory:
db_manager = AsyncDatabaseManager(
pool=shared_pool,
schema=f"tenant_{tenant_id}" # Dedicated schema per tenant
)
memory = LLMemory.from_db_manager(db_manager)
await memory.initialize() # Creates tables in tenant schema
return memory
# Tenant 1 uses schema "tenant_workspace1"
memory_1 = await get_memory_for_tenant("workspace1")
# Tenant 2 uses schema "tenant_workspace2"
memory_2 = await get_memory_for_tenant("workspace2")
# Complete data isolation via schemas
Advantages:
Disadvantages:
Each tenant gets their own database:
# Separate database per tenant
async def get_memory_for_tenant(tenant_id: str) -> LLMemory:
connection_string = f"postgresql://localhost/{tenant_id}_db"
memory = LLMemory(connection_string=connection_string)
await memory.initialize()
return memory
# Tenant 1: database "workspace1_db"
memory_1 = await get_memory_for_tenant("workspace1")
# Tenant 2: database "workspace2_db"
memory_2 = await get_memory_for_tenant("workspace2")
Advantages:
Disadvantages:
For most SaaS applications:
from pgdbm import AsyncDatabaseManager, DatabaseConfig
from llmemory import LLMemory
# Global shared pool (create once at startup)
config = DatabaseConfig(
connection_string="postgresql://localhost/app_db",
min_connections=10,
max_connections=50
)
shared_pool = await AsyncDatabaseManager.create_shared_pool(config)
# Global LLMemory instance (create once)
db_manager = AsyncDatabaseManager(pool=shared_pool, schema="llmemory")
memory = LLMemory.from_db_manager(db_manager)
await memory.initialize()
# Use in request handlers
async def add_document_handler(tenant_id: str, user_id: str, doc_data: dict):
# Validate tenant_id matches authenticated user's tenant
if not verify_tenant_access(tenant_id):
raise PermissionError("Access denied")
result = await memory.add_document(
owner_id=tenant_id, # Tenant isolation
id_at_origin=user_id, # User within tenant
document_name=doc_data["name"],
document_type=doc_data["type"],
content=doc_data["content"]
)
return result
async def search_handler(tenant_id: str, query: str, filters: dict):
# Validate tenant_id
if not verify_tenant_access(tenant_id):
raise PermissionError("Access denied")
results = await memory.search(
owner_id=tenant_id, # Automatic filtering to tenant
query_text=query,
**filters
)
return results
from fastapi import HTTPException, Depends
async def get_current_tenant(user: User = Depends(get_current_user)) -> str:
"""Get tenant ID from authenticated user."""
return user.tenant_id
async def verify_tenant_access(
tenant_id: str,
current_tenant: str = Depends(get_current_tenant)
) -> str:
"""Verify user has access to requested tenant."""
if tenant_id != current_tenant:
raise HTTPException(status_code=403, detail="Access denied")
return tenant_id
# Use in endpoints
@app.post("/documents")
async def add_document(
doc: DocumentCreate,
tenant_id: str = Depends(verify_tenant_access)
):
result = await memory.add_document(
owner_id=tenant_id, # Validated tenant
id_at_origin=doc.user_id,
document_name=doc.name,
document_type=doc.type,
content=doc.content
)
return result
❌ Wrong: Using owner_id from client
@app.post("/documents")
async def add_document(owner_id: str, doc: DocumentCreate):
# SECURITY RISK: Client can specify any owner_id!
result = await memory.add_document(
owner_id=owner_id, # From client - DO NOT DO THIS
id_at_origin=doc.user_id,
document_name=doc.name,
document_type=doc.type,
content=doc.content
)
return result
✅ Right: Derive owner_id from authentication
@app.post("/documents")
async def add_document(
doc: DocumentCreate,
user: User = Depends(get_current_user)
):
# Get owner_id from authenticated user's session
tenant_id = user.tenant_id # From auth, not client
result = await memory.add_document(
owner_id=tenant_id, # Validated from auth
id_at_origin=user.user_id,
document_name=doc.name,
document_type=doc.type,
content=doc.content
)
return result
Complete multi-tenant setup:
from fastapi import FastAPI, Depends, HTTPException
from pgdbm import AsyncDatabaseManager, DatabaseConfig
from llmemory import LLMemory, DocumentType, SearchType
from typing import Optional
# Global state
app = FastAPI()
memory: Optional[LLMemory] = None
@app.on_event("startup")
async def startup():
global memory
# Create shared pool
config = DatabaseConfig(
connection_string="postgresql://localhost/app_db",
min_connections=10,
max_connections=50
)
shared_pool = await AsyncDatabaseManager.create_shared_pool(config)
# Create LLMemory
db_manager = AsyncDatabaseManager(pool=shared_pool, schema="llmemory")
memory = LLMemory.from_db_manager(db_manager)
await memory.initialize()
@app.on_event("shutdown")
async def shutdown():
if memory:
await memory.close()
# Auth dependency
async def get_current_user():
# Your auth logic here
# Return user object with tenant_id
pass
async def get_tenant_id(user = Depends(get_current_user)) -> str:
return user.tenant_id
# Endpoints
@app.post("/api/documents")
async def add_document(
name: str,
content: str,
doc_type: str,
tenant_id: str = Depends(get_tenant_id),
user = Depends(get_current_user)
):
result = await memory.add_document(
owner_id=tenant_id,
id_at_origin=user.user_id,
document_name=name,
document_type=DocumentType(doc_type),
content=content
)
return {
"document_id": str(result.document.document_id),
"chunks_created": result.chunks_created
}
@app.get("/api/search")
async def search(
q: str,
limit: int = 10,
tenant_id: str = Depends(get_tenant_id)
):
results = await memory.search(
owner_id=tenant_id,
query_text=q,
search_type=SearchType.HYBRID,
limit=limit
)
return {"results": [r.to_dict() for r in results]}
@app.get("/api/documents")
async def list_documents(
limit: int = 20,
offset: int = 0,
tenant_id: str = Depends(get_tenant_id)
):
result = await memory.list_documents(
owner_id=tenant_id,
limit=limit,
offset=offset
)
return {
"documents": [d.to_dict() for d in result.documents],
"total": result.total
}
@app.get("/api/statistics")
async def get_stats(tenant_id: str = Depends(get_tenant_id)):
stats = await memory.get_statistics(owner_id=tenant_id)
return {
"document_count": stats.document_count,
"chunk_count": stats.chunk_count,
"total_size_mb": stats.total_size_bytes / 1024 / 1024
}
Support for organization → workspace → user hierarchy:
# Structure: org-123/workspace-456/user-789
# Add document with full hierarchy
await memory.add_document(
owner_id="org-123/workspace-456", # Combined owner ID
id_at_origin="user-789", # User within workspace
document_name="doc.pdf",
document_type=DocumentType.PDF,
content="..."
)
# Search within workspace
results = await memory.search(
owner_id="org-123/workspace-456",
query_text="query",
id_at_origin="user-789" # User's documents
)
# Search across user's documents in workspace
results = await memory.search(
owner_id="org-123/workspace-456",
query_text="query",
id_at_origins=["user-789", "user-123"] # Multiple users
)
# Organization-wide search (if permitted)
# Use separate owner_id per workspace and aggregate client-side
workspaces = ["org-123/workspace-456", "org-123/workspace-789"]
all_results = []
for workspace in workspaces:
results = await memory.search(
owner_id=workspace,
query_text="query",
limit=10
)
all_results.extend(results)
# Get statistics per tenant
async def get_tenant_metrics(tenant_id: str):
stats = await memory.get_statistics(
owner_id=tenant_id,
include_breakdown=True
)
return {
"tenant_id": tenant_id,
"documents": stats.document_count,
"chunks": stats.chunk_count,
"size_mb": stats.total_size_bytes / 1024 / 1024,
"document_types": {
str(k): v for k, v in (stats.document_type_breakdown or {}).items()
}
}
# Monitor all tenants
active_tenants = ["tenant-1", "tenant-2", "tenant-3"]
metrics = [await get_tenant_metrics(t) for t in active_tenants]
# Alert on unusual usage
for metric in metrics:
if metric["size_mb"] > 1000: # 1 GB limit
send_alert(f"Tenant {metric['tenant_id']} exceeds storage limit")
async def export_tenant_data(tenant_id: str, output_path: str):
"""Export all documents for a tenant."""
offset = 0
limit = 100
with open(output_path, "w") as f:
while True:
result = await memory.list_documents(
owner_id=tenant_id,
limit=limit,
offset=offset
)
if not result.documents:
break
for doc in result.documents:
# Get document with chunks
doc_data = await memory.get_document(
document_id=doc.document_id,
include_chunks=True
)
# Write to export file
f.write(json.dumps({
"document_id": str(doc_data.document.document_id),
"document_name": doc_data.document.document_name,
"metadata": doc_data.document.metadata,
"chunks": [chunk.content for chunk in (doc_data.chunks or [])]
}) + "\n")
offset += limit
if offset >= result.total:
break
# Export tenant data
await export_tenant_data("tenant-123", "tenant-123-export.jsonl")
async def delete_tenant_data(tenant_id: str):
"""Delete all data for a tenant (GDPR compliance)."""
# Get all documents for tenant
offset = 0
deleted_total = 0
while True:
result = await memory.list_documents(
owner_id=tenant_id,
limit=100,
offset=0 # Always 0 since we're deleting
)
if not result.documents:
break
# Delete batch
doc_ids = [str(doc.document_id) for doc in result.documents]
delete_result = await memory.delete_documents(
owner_id=tenant_id,
document_ids=doc_ids
)
deleted_total += delete_result.deleted_count
return {
"tenant_id": tenant_id,
"documents_deleted": deleted_total
}
# Delete tenant
result = await delete_tenant_data("tenant-to-delete")
print(f"Deleted {result['documents_deleted']} documents")
❌ Wrong: Forgetting owner_id validation
@app.get("/search")
async def search(owner_id: str, q: str):
# User can pass any owner_id!
results = await memory.search(owner_id=owner_id, query_text=q)
return results
✅ Right: Always validate from auth
@app.get("/search")
async def search(q: str, user = Depends(get_current_user)):
# owner_id from authenticated session
results = await memory.search(
owner_id=user.tenant_id,
query_text=q
)
return results
❌ Wrong: Mixing tenants in id_at_origin
# Don't use id_at_origin for tenant separation
await memory.add_document(
owner_id="shared", # Same owner
id_at_origin="tenant-1", # Trying to use this for tenant
document_name="doc",
content="..."
)
# This doesn't provide proper isolation!
✅ Right: Use owner_id for tenant isolation
await memory.add_document(
owner_id="tenant-1", # Proper tenant separation
id_at_origin="user-123", # User within tenant
document_name="doc",
content="..."
)
basic-usage - Core operations with owner_idhybrid-search - Search within tenant boundariesrag - Building multi-tenant RAG systemsData Isolation Guarantees:
owner_id at the database levelPerformance at Scale:
Schema vs owner_id:
Embedding Providers: