Multi-Tenancy Patterns
Critical patterns for ensuring tenant isolation and preventing cross-tenant data leaks.
---
🔴 CRITICAL IMPORTANCE
**Multi-tenant isolation is the most critical security aspect of ATOM SaaS.**
A single mistake can lead to:
- 🔴 **Cross-tenant data leaks** (Tenant A sees Tenant B's data)
- 🔴 **Compliance violations** (GDPR, SOC2, HIPAA)
- 🔴 **Security breaches** (Unauthorized access)
- 🔴 **Legal liability** ( Lawsuits, fines)
**Always verify tenant context. Never skip tenant filtering.**
---
Tenant Isolation Layers
ATOM SaaS implements defense-in-depth with 5 isolation layers:
Layer 1: Subdomain Routing
Each tenant gets unique subdomain mapped to tenant_id.
**Implementation:**
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getTenantFromSubdomain } from '@/lib/tenant/tenant-service';
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
// Skip for main domain
if (subdomain === 'app' || subdomain === 'www') {
return NextResponse.next();
}
// Resolve tenant from subdomain
const tenant = await getTenantFromSubdomain(subdomain);
if (!tenant) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
// Add tenant to request headers for downstream use
const requestHeaders = new Headers(request.headers);
requestHeaders.set('X-Tenant-ID', tenant.id);
requestHeaders.set('X-Tenant-Name', tenant.name);
return NextResponse.next({
request: { headers: requestHeaders }
});
}
export const config = {
matcher: ['/api/:path*', '/agents/:path*']
};**✅ CORRECT:**
// Always extract tenant from request
export async function GET(request: NextRequest) {
const tenantId = request.headers.get('X-Tenant-ID');
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant context required' },
{ status: 400 }
);
}
// Use tenantId for all operations
const agents = await db.agents.findMany({
where: { tenantId }
});
return NextResponse.json({ agents });
}**❌ WRONG:**
// NEVER skip tenant extraction
export async function GET(request: NextRequest) {
// CROSS-TENANT LEAK!
const agents = await db.agents.findMany();
return NextResponse.json({ agents });
}---
Layer 2: Tenant Context Extraction
Extract and validate tenant context on every request.
**Frontend (API Routes):**
// src/lib/tenant/tenant-service.ts
import { headers } from 'next/headers';
import { Database } from '@/lib/database';
export async function getTenantFromRequest(request?: Request) {
const headersList = request ? headers() : await headers();
const tenantId = headersList.get('X-Tenant-ID');
if (!tenantId) {
return null;
}
const db = new Database();
const tenant = await db.tenants.findUnique({
where: { id: tenantId }
});
if (!tenant) {
throw new Error('Tenant not found');
}
if (tenant.status !== 'active') {
throw new Error('Tenant is not active');
}
return tenant;
}**Backend (FastAPI):**
# core/tenant.py
from fastapi import Header, HTTPException
from sqlalchemy.orm import Session
async def get_tenant_from_request(
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: Session = Depends(get_db)
) -> Tenant:
tenant = db.query(Tenant).filter(
Tenant.id == x_tenant_id,
Tenant.status == "active"
).first()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant
# Usage in endpoints
@router.get("/agents")
async def list_agents(
tenant: Tenant = Depends(get_tenant_from_request),
db: Session = Depends(get_db)
):
agents = db.query(AgentRegistry).filter(
AgentRegistry.tenant_id == tenant.id
).all()
return {"agents": agents}---
Layer 3: Application-Level Filtering
Every database query MUST filter by tenant_id.
**TypeScript (Prisma):**
// ✅ CORRECT: Always filter by tenantId
const agents = await db.agentRegistry.findMany({
where: {
tenantId: tenant.id,
status: 'active'
}
});
// ❌ WRONG: No tenant filter
const agents = await db.agentRegistry.findMany({
where: {
status: 'active'
}
});**Python (SQLAlchemy):**
# ✅ CORRECT: Always filter by tenant_id
def get_agents(tenant_id: str, db: Session):
return db.query(AgentRegistry).filter(
AgentRegistry.tenant_id == tenant_id
).all()
# ❌ WRONG: No tenant filter
def get_agents(db: Session):
return db.query(AgentRegistry).all()**❌ NEVER use raw SQL without tenant filter:**
# DANGEROUS: Cross-tenant leak possible
query = "SELECT * FROM agents WHERE status = 'active'"
results = db.execute(query)
# SAFE: Always include tenant_id
query = """
SELECT * FROM agents
WHERE tenant_id = :tenant_id
AND status = 'active'
"""
results = db.execute(query, {"tenant_id": tenant_id})---
Layer 4: Row-Level Security (RLS)
PostgreSQL RLS provides database-level tenant isolation.
**Migration:**
# alembic/versions/xxx_add_rls.py
from alembic import op
import sqlalchemy as sa
def upgrade():
# Enable RLS on tables
op.execute("ALTER TABLE agents ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE sessions ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE episodes ENABLE ROW LEVEL SECURITY")
# Create tenant isolation policy
op.execute("""
CREATE POLICY tenant_isolation ON agents
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
""")
op.execute("""
CREATE POLICY tenant_isolation ON sessions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
""")
op.execute("""
CREATE POLICY tenant_isolation ON episodes
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
""")**Setting Tenant Context:**
# core/database.py
from contextvars import ContextVar
_tenant_id_ctx: ContextVar[str | None] = ContextVar('_tenant_id_ctx', default=None)
def set_tenant_context(tenant_id: str):
"""Set tenant context for current request."""
_tenant_id_ctx.set(tenant_id)
# Set PostgreSQL RLS variable
db.execute(
"SET LOCAL app.current_tenant_id = '%s'" % tenant_id
)
def get_tenant_context() -> str | None:
"""Get current tenant context."""
return _tenant_id_ctx.get()**Middleware:**
# core/middleware.py
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class TenantContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Extract tenant from header
tenant_id = request.headers.get("X-Tenant-ID")
if tenant_id:
# Validate tenant exists
tenant = db.query(Tenant).filter(
Tenant.id == tenant_id
).first()
if tenant:
# Set tenant context for request
set_tenant_context(tenant_id)
response = await call_next(request)
# Clear tenant context after request
_tenant_id_ctx.set(None)
return response
# Add to FastAPI app
app.add_middleware(TenantContextMiddleware)---
Layer 5: Storage Isolation
S3 Prefix Isolation
Each tenant gets dedicated S3 prefix.
// src/lib/storage/s3.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
}
});
export async function uploadFile(
tenantId: string,
file: File,
filename: string
) {
// ✅ CORRECT: Use tenant-specific prefix
const key = `atom-saas/${tenantId}/uploads/${filename}`;
// ❌ WRONG: No tenant isolation
// const key = `uploads/${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: file,
ACL: 'private' # Never use 'public'
});
await s3Client.send(command);
return {
url: `s3://${process.env.S3_BUCKET}/${key}`,
key
};
}**S3 Bucket Policy:**
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::atom-saas/*",
"Condition": {
"StringNotLike": {
"aws:userId": "tenant-*"
}
}
}
]
}Redis Namespace Isolation
// src/lib/cache/redis.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export class TenantCache {
constructor(private tenantId: string) {}
private key(key: string): string {
// ✅ CORRECT: Namespace by tenant
return `tenant:${this.tenantId}:${key}`;
}
async get(key: string): Promise<string | null> {
return redis.get(this.key(key));
}
async set(key: string, value: string, ttl?: number): Promise<void> {
if (ttl) {
await redis.setex(this.key(key), ttl, value);
} else {
await redis.set(this.key(key), value);
}
}
async delete(key: string): Promise<void> {
await redis.del(this.key(key));
}
// ❌ WRONG: No tenant namespace
// async get(key: string): Promise<string | null> {
// return redis.get(key);
// }
}
// Usage
const cache = new TenantCache(tenantId);
await cache.set('agent:123', 'cached-data', 300);---
Common Patterns
Pattern 1: API Route with Tenant
// app/api/agents/route.ts
import { getTenantFromRequest } from '@/lib/tenant/tenant-service';
import { db } from '@/lib/database';
export async function GET(request: Request) {
// 1. Extract tenant
const tenant = await getTenantFromRequest(request);
if (!tenant) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
// 2. Query with tenant filter
const agents = await db.agents.findMany({
where: {
tenantId: tenant.id,
status: 'active'
},
orderBy: {
createdAt: 'desc'
}
});
// 3. Return tenant-scoped results
return NextResponse.json({
success: true,
data: { agents },
meta: {
tenant: {
id: tenant.id,
name: tenant.name
}
}
});
}
export async function POST(request: Request) {
// 1. Extract tenant
const tenant = await getTenantFromRequest(request);
if (!tenant) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
// 2. Parse request body
const body = await request.json();
// 3. Validate tenant limits
const agentCount = await db.agents.count({
where: { tenantId: tenant.id }
});
const tierLimits = {
free: 3,
solo: 10,
team: 25,
enterprise: -1 // unlimited
};
const limit = tierLimits[tenant.tier];
if (limit !== -1 && agentCount >= limit) {
return NextResponse.json(
{ error: 'Agent limit exceeded' },
{ status: 403 }
);
}
// 4. Create with tenant_id
const agent = await db.agents.create({
data: {
...body,
tenantId: tenant.id,
createdBy: tenant.userId
}
});
return NextResponse.json({
success: true,
data: { agent }
});
}Pattern 2: Backend Endpoint with Tenant
# api/routes/agent_routes.py
from fastapi import Depends, HTTPException, Header
from sqlalchemy.orm import Session
from core.database import get_db
from core.tenant import get_tenant_from_request
@router.get("/agents")
async def list_agents(
tenant: Tenant = Depends(get_tenant_from_request),
db: Session = Depends(get_db)
):
"""List all agents for current tenant."""
agents = db.query(AgentRegistry).filter(
AgentRegistry.tenant_id == tenant.id,
AgentRegistry.is_deleted == False
).order_by(
AgentRegistry.created_at.desc()
).all()
return {
"success": True,
"data": {
"agents": [agent.to_dict() for agent in agents]
},
"meta": {
"tenant": {
"id": tenant.id,
"name": tenant.name
}
}
}
@router.post("/agents")
async def create_agent(
agent_data: AgentCreate,
tenant: Tenant = Depends(get_tenant_from_request),
db: Session = Depends(get_db)
):
"""Create new agent for current tenant."""
# Check tenant limits
agent_count = db.query(AgentRegistry).filter(
AgentRegistry.tenant_id == tenant.id,
AgentRegistry.is_deleted == False
).count()
tier_limits = {
"free": 3,
"solo": 10,
"team": 25,
"enterprise": -1
}
limit = tier_limits.get(tenant.tier, 3)
if limit != -1 and agent_count >= limit:
raise HTTPException(
status_code=403,
detail=f"Agent limit exceeded for {tenant.tier} tier"
)
# Create agent with tenant_id
agent = AgentRegistry(
**agent_data.dict(),
tenant_id=tenant.id,
created_by=tenant.user_id
)
db.add(agent)
db.commit()
db.refresh(agent)
return {
"success": True,
"data": {
"agent": agent.to_dict()
}
}Pattern 3: Background Jobs with Tenant
// lib/jobs/process-episodes.ts
import { db } from '@/lib/database';
export async function processEpisodes(tenantId: string) {
// ✅ CORRECT: Tenant-scoped query
const episodes = await db.episodes.findMany({
where: {
tenantId: tenantId,
status: 'pending'
}
});
for (const episode of episodes) {
await processEpisode(episode, tenantId);
}
}
// ❌ WRONG: No tenant filter - processes ALL episodes!
export async function processEpisodes() {
const episodes = await db.episodes.findMany({
where: {
status: 'pending'
}
});
for (const episode of episodes) {
await processEpisode(episode);
}
}---
Testing Tenant Isolation
Unit Tests
// __tests__/tenant-isolation.test.ts
import { describe, it, expect } from 'vitest';
import { db } from '@/lib/database';
describe('Tenant Isolation', () => {
it('should not leak agents across tenants', async () => {
// Create agents for different tenants
const agent1 = await db.agents.create({
data: {
name: 'Agent 1',
tenantId: 'tenant-1',
}
});
const agent2 = await db.agents.create({
data: {
name: 'Agent 2',
tenantId: 'tenant-2',
}
});
// Query as tenant-1
const tenant1Agents = await db.agents.findMany({
where: { tenantId: 'tenant-1' }
});
expect(tenant1Agents).toHaveLength(1);
expect(tenant1Agents[0].id).toBe(agent1.id);
// Query as tenant-2
const tenant2Agents = await db.agents.findMany({
where: { tenantId: 'tenant-2' }
});
expect(tenant2Agents).toHaveLength(1);
expect(tenant2Agents[0].id).toBe(agent2.id);
});
it('should enforce RLS policies', async () => {
// Set tenant context
await db.execute(`
SET LOCAL app.current_tenant_id = 'tenant-1'
`);
// This should only return tenant-1 agents
const agents = await db.$queryRaw`
SELECT * FROM agents
`;
agents.forEach(agent => {
expect(agent.tenant_id).toBe('tenant-1');
});
});
});Integration Tests
# tests/test_tenant_isolation.py
import pytest
from core.database import SessionLocal
from core.models import Tenant, AgentRegistry
def test_tenant_isolation():
"""Test that tenants cannot access each other's data."""
db = SessionLocal()
# Create two tenants
tenant1 = Tenant(id="tenant-1", name="Tenant 1", tier="free")
tenant2 = Tenant(id="tenant-2", name="Tenant 2", tier="free")
db.add_all([tenant1, tenant2])
db.commit()
# Create agents for each tenant
agent1 = AgentRegistry(
id="agent-1",
name="Agent 1",
tenant_id="tenant-1"
)
agent2 = AgentRegistry(
id="agent-2",
name="Agent 2",
tenant_id="tenant-2"
)
db.add_all([agent1, agent2])
db.commit()
# Set tenant context to tenant-1
db.execute("SET LOCAL app.current_tenant_id = 'tenant-1'")
# Query should only return tenant-1's agent
agents = db.query(AgentRegistry).all()
assert len(agents) == 1
assert agents[0].id == "agent-1"
assert agents[0].tenant_id == "tenant-1"
db.close()---
Security Checklist
Before deploying any code, verify:
- [ ] All API routes extract tenant from request
- [ ] All database queries filter by
tenant_id - [ ] All S3 paths include tenant prefix
- [ ] All Redis keys use tenant namespace
- [ ] All background jobs scoped to tenant
- [ ] All webhooks validate tenant context
- [ ] RLS policies enabled on all tables
- [ ] Tenant context set in middleware
- [ ] Tests verify tenant isolation
- [ ] No raw SQL without tenant filter
- [ ] No
SELECT *without WHERE tenant_id - [ ] Admin bypass routes properly secured
---
Common Mistakes
❌ Mistake 1: Forgetting Tenant Filter
// WRONG: Returns ALL agents across ALL tenants
const agents = await db.agents.findMany();
// CORRECT: Filter by tenant
const agents = await db.agents.findMany({
where: { tenantId: tenant.id }
});❌ Mistake 2: Subqueries Without Tenant
// WRONG: Subquery lacks tenant filter
const agents = await db.agents.findMany({
where: {
sessions: {
some: {
status: 'active'
// Missing tenantId!
}
}
}
});
// CORRECT: Include tenant in subquery
const agents = await db.agents.findMany({
where: {
tenantId: tenant.id,
sessions: {
some: {
tenantId: tenant.id,
status: 'active'
}
}
}
});❌ Mistake 3: Cache Without Namespace
// WRONG: Shared across all tenants
await redis.set('agent:123', data);
// CORRECT: Namespaced by tenant
await redis.set(`tenant:${tenantId}:agent:123`, data);❌ Mistake 4: File Upload Without Tenant Path
// WRONG: Files from all tenants mixed together
const path = `/uploads/${filename}`;
// CORRECT: Tenant-isolated path
const path = `/${tenantId}/uploads/${filename}`;---
References
- **Implementation:**
src/lib/tenant/tenant-service.ts - **Middleware:**
middleware.ts - **RLS Migration:**
backend-saas/alembic/versions/xxx_add_rls.py - **Tests:**
__tests__/tenant-isolation.test.ts
---
**Last Updated:** 2025-02-06
**Status:** CRITICAL - READ AND UNDERSTAND