ATOM Documentation

← Back to App

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