~learn.
?

Day 0148 minPreface+60 XP

Tenant Isolation Strategies.

System Design Mastery Series — Multi-Tenancy, Security, and Compliance Week

4 stages21 code

watch it first · newThe whole lesson in a 20-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

You've built an amazing SaaS product. Users love it. Growth is exploding.

Then your first enterprise customer shows up:

The enterprise reality

Sales team: "Great news! We just closed Acme Corp — $500K ARR!"

Acme Corp's security team sends their requirements:

Acme Corp security requirements
  1. "Our data must be completely isolated from other customers"
  2. "We need audit logs of every access to our data"
  3. "Can you prove that your engineers can't see our data?"
  4. "What happens if another customer's bug affects our instance?"
  5. "We require SOC 2 Type II and annual penetration testing"
You look at your architecture — current state
  • Single PostgreSQL database
  • All customers in same tables
  • WHERE tenant_id = ? everywhere (hopefully)
  • Shared Redis cache
  • One Elasticsearch cluster

Question: Can you prove isolation? Answer: Not really.

This is the multi-tenancy challenge.

Today, we'll learn to design systems where thousands of customers share infrastructure safely — from startups to enterprises.

ExplainerThe whole lesson: tenant isolation

Multi-tenancy replaces a $500-a-month stack per customer with shared servers and a shared database at about $50 each, so isolation depends on the implementation: shared tables with tenant_id (millions of tenants), schema per tenant (thousands), or database per tenant (hundreds, per-tenant backups), with a hybrid of shared tables plus RLS and dedicated databases for enterprise as the default. The tenant comes from the verified JWT and reaches every layer: the repository's tenant filter, PostgreSQL RLS, tenant-prefixed cache keys, logs and background jobs. The classic leaks are a missing WHERE clause, a trusted X-Tenant-ID header and an unprefixed cache key.

Part I · Stage 1 of 4 · 10 min

Part I: Foundations#

Chapter 1: What Is Multi-Tenancy?#

1.1 The Simple Definition#

Multi-tenancy is an architecture where a single instance of software serves multiple customers (tenants), with each tenant's data and configuration isolated from others.

Multi-tenancy visualization

Single-Tenant (old world)

  • Customer A — own App Server + own DB — $500/mo
  • Customer B — own App Server + own DB — $500/mo
  • Customer C — own App Server + own DB — $500/mo

Cost per customer: High Isolation: Perfect Scalability: Limited

Multi-Tenant (modern SaaS)

Shared Infrastructure

  • Application Servers — Tenant A │ Tenant B │ Tenant C
  • Shared Database — A, B, C (isolated)
  • $50/mo · $50/mo · $50/mo

Cost per customer: Low Isolation: Depends on implementation Scalability: High

1.2 Why Multi-Tenancy Matters#

Business drivers
Cost Efficiency
  • Shared infrastructure = lower cost per customer
  • $50/month pricing impossible with dedicated infrastructure
  • Enables SMB and self-serve markets
  • Cloud-native economics
Operational Efficiency
  • One codebase to maintain
  • One deployment to manage
  • Updates roll out to everyone
  • Simpler monitoring and debugging
Scalability
  • Add customers without adding servers 1:1
  • Better resource utilization
  • Elastic scaling across tenants
  • Standard SaaS economics
The challenge
  • How do you prevent Tenant A from seeing Tenant B's data?
  • How do you prevent Tenant A from affecting Tenant B's performance?
  • How do you meet enterprise security requirements?
  • How do you handle different compliance needs per tenant?

1.3 The Isolation Spectrum#

Isolation levels
Level Model What it means Isolation Security Use case
0 NO ISOLATION (Don't do this) All data in same tables, no tenant identifier — None Never
1 SHARED DATABASE, SHARED SCHEMA Same database, same tables, tenant_id column Logical (application-enforced) Low-Medium Startups, SMB SaaS
2 SHARED DATABASE, SEPARATE SCHEMAS Same database server, schema per tenant Logical (database-enforced) Medium Growing SaaS, some enterprise
3 SEPARATE DATABASES Database instance per tenant (same server cluster) Physical at database level Medium-High Enterprise SaaS
4 SEPARATE INFRASTRUCTURE Dedicated servers, network isolation Physical High Regulated industries, large enterprise
5 SEPARATE CLOUD ACCOUNTS Different AWS/GCP accounts per tenant Complete Highest Government, healthcare, finance

Chapter 2: Isolation Patterns Deep Dive#

2.1 Pattern 1: Shared Tables with Tenant ID#

Shared tables pattern
Database schema — users table
id tenant_id email name created_at
1 acme a@acme.com Alice 2024-01-01
2 acme b@acme.com Bob 2024-01-02
3 globex c@globex.. Carol 2024-01-03
4 initech d@inite... Dave 2024-01-04

Every table has tenant_id column. Every query MUST include WHERE tenant_id = ?

Pros
  • Simplest to implement
  • Lowest cost (one database)
  • Easy cross-tenant operations (admin)
  • Simple backup/restore
  • Scales to millions of tenants (no per-tenant objects to create)
Cons
  • Risk of data leakage (forgot WHERE clause)
  • No native database isolation
  • Noisy neighbor at database level
  • Hard to give tenant their own backup
  • Enterprise customers may reject this

2.2 Pattern 2: Schema Per Tenant#

Schema per tenant pattern
Database structure — PostgreSQL Database
  • Schema: tenant_acme
    • users · orders · settings
  • Schema: tenant_globex
    • users · orders · settings
  • Schema: tenant_initech
    • users · orders · settings

Connection sets search_path per tenant: SET search_path TO tenant_acme;

Pros
  • Database-level isolation
  • Can't accidentally query wrong tenant
  • Per-tenant backup possible
  • Schema migrations per tenant possible
  • Better security posture
  • PostgreSQL RLS can add extra layer
Cons
  • Schema proliferation (10K tenants = 10K schemas)
  • Migrations more complex
  • Connection management harder
  • Cross-tenant queries need explicit schema
  • Some ORMs don't handle this well

2.3 Pattern 3: Database Per Tenant#

Database per tenant pattern
Infrastructure structure — Database Cluster
  • Database: db_acme — (all acme tables)
  • Database: db_globex — (all globex tables)
  • Database: db_initech — (all initech tables)

Each tenant gets dedicated connection string:

  • acme: postgresql://cluster/db_acme
  • globex: postgresql://cluster/db_globex
Pros
  • Strong isolation at database level
  • Independent backup/restore per tenant
  • Can put large tenants on dedicated hardware
  • Easier compliance (data residency)
  • Can offer "dedicated" tier at higher price
  • Clear resource accounting per tenant
Cons
  • Connection pool per tenant
  • Higher operational complexity
  • Schema migrations across all databases
  • Monitoring complexity increases
  • Cost increases with tenant count
Your turn · checkpoint 1not answered

Pick a pattern: 50,000 small teams and one bank

You have 50,000 self-serve tenants paying $50 a month, and one bank that wants its own backups and its data kept in one region. Which pattern do you give each, and why not one pattern for everyone?

Think it through, or jot it down. Then compare.

One strong answer

  • Self-serve: shared tables. They scale to millions of tenants with no per-tenant objects, at the lowest cost. 50,000 schemas or databases would mean 50,000 things to migrate and monitor.
  • The bank: database per tenant. It gives per-tenant backup and restore, and data residency is easy because the database can sit where the bank needs it.
  • Why both: database per tenant tops out around hundreds of tenants and costs more with each one, so it belongs to the customers who pay for it.

2.4 Pattern Comparison#

Aspect Shared Tables Schema/Tenant DB/Tenant
Cost Lowest Low-Medium Medium-High
Isolation Application Database Physical
Max Tenants Millions Thousands Hundreds
Complexity Low Medium High
Backup Granularity All or nothing Per schema Per tenant
Compliance Basic Better Best
Enterprise Ready Rarely Sometimes Yes
Data Residency Difficult Possible Easy

Chapter 3: Tenant Context and Propagation#

3.1 The Tenant Context Problem#

DiagramTenant context challenge · request flow
%% title: Tenant context challenge · request flow
flowchart TD
  C@{ icon: "sd:laptop", label: "Client" } --> GW@{ icon: "sd:gateway", label: "API Gateway" } --> S["Service"]:::core --> DB@{ icon: "sd:database", label: "Database" }
  GW -.-> Q1{"tenant_id?"}:::note
  S -.-> Q2{"tenant_id?"}:::note
  DB -.-> Q3{"tenant_id?"}:::note
  class C client
  class GW edge
  class DB store
Questions
  • How does the API Gateway know which tenant?
  • How does the service know which tenant?
  • How does the database query know which tenant?
  • How do async jobs know which tenant?
  • How do background workers know which tenant?
Without consistent tenant context
  • Queries might miss WHERE clause
  • Logs might not show tenant
  • Metrics not attributed correctly
  • Debugging becomes impossible
  • Security incidents undetectable

3.2 Tenant Context Implementation#

pythonTenantContext · 130 lines
# tenant_isolation/context.py

"""
Tenant context management for multi-tenant applications.

The tenant context must flow through every layer:
- HTTP request → Service → Database → Cache → Queue → Background job
"""

from contextvars import ContextVar
from dataclasses import dataclass
from typing import Optional
import functools


# Thread-safe tenant context using contextvars
_tenant_context: ContextVar[Optional['TenantContext']] = ContextVar(
    'tenant_context', 
    default=None
)


@dataclass(frozen=True)
class TenantContext:
    """
    Immutable tenant context that flows through the application.
    
    Frozen to prevent accidental modification.
    """
    tenant_id: str
    tenant_name: str
    plan: str  # 'free', 'pro', 'enterprise'
    region: str  # For data residency
    features: frozenset  # Enabled features
    
    # Security context
    isolation_level: str  # 'shared', 'schema', 'database'
    encryption_key_id: Optional[str] = None  # For tenant-specific encryption
    is_active: bool = True  # False when the tenant is suspended


def get_current_tenant() -> TenantContext:
    """
    Get the current tenant context.
    
    Raises TenantContextError if not in tenant context.
    """
    ctx = _tenant_context.get()
    if ctx is None:
        raise TenantContextError("No tenant context set. This is a bug!")
    return ctx


def get_current_tenant_id() -> str:
    """Convenience method to get just the tenant ID."""
    return get_current_tenant().tenant_id


def set_tenant_context(ctx: TenantContext) -> None:
    """Set the tenant context for the current execution."""
    _tenant_context.set(ctx)


def clear_tenant_context() -> None:
    """Clear the tenant context."""
    _tenant_context.set(None)


class TenantContextError(Exception):
    """Raised when tenant context is missing or invalid."""
    pass


# Context manager for tenant scope
class tenant_scope:
    """
    Context manager for executing code in a tenant's context.
    
    Usage:
        with tenant_scope(tenant_ctx):
            # All code here runs in tenant context
            users = user_service.get_users()  # Automatically filtered
    """
    
    def __init__(self, tenant: TenantContext):
        self.tenant = tenant
        self.previous = None
    
    def __enter__(self):
        self.previous = _tenant_context.get()
        _tenant_context.set(self.tenant)
        return self.tenant
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        _tenant_context.set(self.previous)
        return False


# Decorator for tenant-required functions
def requires_tenant(func):
    """
    Decorator that ensures function runs within tenant context.
    
    Usage:
        @requires_tenant
        def get_user_data():
            tenant = get_current_tenant()
            # ...
    """
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if _tenant_context.get() is None:
            raise TenantContextError(
                f"Function {func.__name__} requires tenant context"
            )
        return func(*args, **kwargs)
    return wrapper


# Async version
def requires_tenant_async(func):
    """Async version of requires_tenant decorator."""
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        if _tenant_context.get() is None:
            raise TenantContextError(
                f"Function {func.__name__} requires tenant context"
            )
        return await func(*args, **kwargs)
    return wrapper
Your turn · checkpoint 2not answered

What breaks if the client names its own tenant?

A request arrives with a valid login for a user at globex, on acme.myapp.com, with X-Tenant-ID: acme. Which of these three sources should decide the tenant, and what should the middleware do here?

Think it through, or jot it down. Then compare.

One strong answer

  • The JWT claim decides, because the auth layer has verified it. The subdomain and the header are set by the client, so they're only hints.
  • Here the hint (acme) disagrees with the claim (globex), so the request is rejected. The middleware never picks the client's value.
  • Trusting the header would let any logged-in user read another tenant's data just by changing one header.

3.3 Extracting Tenant from Requests#

pythonTenantMiddleware · 168 lines
# tenant_isolation/middleware.py

"""
Middleware for extracting and setting tenant context from HTTP requests.
"""

from typing import Optional
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
import logging

logger = logging.getLogger(__name__)


class TenantMiddleware(BaseHTTPMiddleware):
    """
    Middleware that extracts tenant context from requests.
    
    Tenant can be identified via:
    1. JWT claim: token.tenant_id = 'acme'  (authoritative)
    2. Subdomain: acme.myapp.com → 'acme'   (hint; must match the JWT)
    3. Header: X-Tenant-ID: acme            (hint; must match the JWT)
    4. Query param, for webhooks only        (after signature verification)

    Assumes an auth middleware has already verified the JWT and set
    request.state.user. Middleware exceptions bypass FastAPI's
    HTTPException handlers, so errors are returned as responses.
    """
    
    def __init__(self, app, tenant_service):
        super().__init__(app)
        self.tenant_service = tenant_service
    
    async def dispatch(self, request: Request, call_next):
        # Skip tenant resolution for public endpoints
        if self._is_public_endpoint(request.url.path):
            return await call_next(request)
        
        # Extract tenant identifier
        tenant_id = await self._extract_tenant_id(request)
        
        if not tenant_id:
            return JSONResponse(
                status_code=400,
                content={"detail": "Tenant identification required"}
            )
        
        # Load tenant context from database/cache
        tenant_ctx = await self.tenant_service.get_tenant_context(tenant_id)
        
        if not tenant_ctx:
            return JSONResponse(
                status_code=404,
                content={"detail": f"Tenant '{tenant_id}' not found"}
            )
        
        if not tenant_ctx.is_active:
            return JSONResponse(
                status_code=403,
                content={"detail": "Tenant account is suspended"}
            )
        
        # Set context for this request
        with tenant_scope(tenant_ctx):
            # Add tenant to request state for logging
            request.state.tenant_id = tenant_id
            
            # Process request
            response = await call_next(request)
            
            # Add tenant to response headers (for debugging)
            response.headers["X-Tenant-ID"] = tenant_id
            
            return response
    
    async def _extract_tenant_id(self, request: Request) -> Optional[str]:
        """
        Extract tenant ID from request using multiple strategies.
        Never trust a client-supplied value on its own (see Mistake 2).
        """
        
        # Strategy 1: JWT claim from the authenticated user (authoritative)
        user = getattr(request.state, "user", None)
        claimed = user.tenant_id if user else None
        
        # Strategies 2-3: subdomain or header are only hints
        host = request.headers.get("host", "").split(":")[0]
        subdomain = host.split(".")[0] if host.count(".") >= 2 else None
        if subdomain in ("www", "api", "app"):
            subdomain = None
        hint = subdomain or request.headers.get("X-Tenant-ID")
        
        if claimed:
            if hint and hint != claimed:
                return None  # Mismatch: reject, never pick the client's value
            return claimed
        
        # Strategy 4: unauthenticated webhooks. The caller must verify the
        # webhook signature and that it belongs to this tenant before use.
        if request.url.path.startswith("/webhooks/"):
            return request.query_params.get("tenant_id")
        
        return None
    
    def _is_public_endpoint(self, path: str) -> bool:
        """Check if endpoint doesn't require tenant context."""
        public_paths = [
            "/health",
            "/metrics",
            "/api/auth/login",
            "/api/auth/register",
            "/api/tenants/create",  # Creating new tenant
        ]
        return any(path.startswith(p) for p in public_paths)


class TenantService:
    """
    Service for loading and caching tenant configuration.
    """
    
    def __init__(self, db, cache):
        self.db = db
        self.cache = cache
    
    async def get_tenant_context(self, tenant_id: str) -> Optional[TenantContext]:
        """
        Load tenant context, with caching.
        """
        
        # Check cache first
        cache_key = f"tenant:{tenant_id}"
        cached = await self.cache.get(cache_key)
        
        if cached:
            return TenantContext(**cached)
        
        # Load from database
        tenant = await self.db.fetchrow(
            """
            SELECT 
                id, name, plan, region, features,
                isolation_level, encryption_key_id, is_active
            FROM tenants
            WHERE id = $1
            """,
            tenant_id
        )
        
        if not tenant:
            return None
        
        ctx = TenantContext(
            tenant_id=tenant["id"],
            tenant_name=tenant["name"],
            plan=tenant["plan"],
            region=tenant["region"],
            features=frozenset(tenant["features"]),
            isolation_level=tenant["isolation_level"],
            encryption_key_id=tenant["encryption_key_id"],
            is_active=tenant["is_active"]
        )
        
        # Cache for 5 minutes
        await self.cache.set(cache_key, ctx.__dict__, ttl=300)
        
        return ctx
Quick check3 questions · +5 XP each

Isolation patterns and tenant context

  1. Q1/3 An enterprise prospect needs its data restorable on its own and stored in the EU, while your other tenants stay in the US. Which pattern makes this easiest?

    Answer & explanation

    Answer: A

    Database per tenant gives per-tenant backup/restore and makes data residency easy, as the comparison table shows. Schemas share one database server, so placing one tenant elsewhere is harder, and shared tables make backup "all or nothing".

  2. Q2/3 Why is the current tenant kept in a ContextVar instead of a plain module-level global?

    Answer & explanation

    Answer: C

    One process serves many requests at once. A single global would be overwritten by whichever request set it last, mixing tenants up. A ContextVar gives each execution context its own value; it is not persisted anywhere and does not reach the database by itself.

  3. Q3/3 A service gets a request but the tenant context was never set. What does get_current_tenant() do, and why is that the right choice?

    Answer & explanation

    Answer: D

    Failing loudly is the safe default: without a tenant, a query might miss its WHERE clause and touch every tenant's data. Any "fallback" tenant, whether a default or a previous request's, is exactly how cross-tenant leaks happen.

Part II · Stage 2 of 4 · 24 min

Part II: Implementation#

Chapter 4: Row-Level Security Implementation#

4.1 Application-Level Enforcement#

pythonTenantAwareRepository · 195 lines
# tenant_isolation/repository.py

"""
Repository pattern with automatic tenant filtering.

All database access goes through repositories that automatically
apply tenant filtering.
"""

from typing import TypeVar, Generic, List, Optional
from dataclasses import dataclass
import logging

logger = logging.getLogger(__name__)

T = TypeVar('T')


class TenantAwareRepository(Generic[T]):
    """
    Base repository that automatically filters by tenant.
    
    All queries include tenant_id filter.
    All inserts include tenant_id.
    """
    
    def __init__(self, db, table_name: str, model_class: type):
        self.db = db
        self.table_name = table_name
        self.model_class = model_class
    
    async def find_by_id(self, id: str) -> Optional[T]:
        """
        Find record by ID within current tenant.
        """
        tenant_id = get_current_tenant_id()
        
        row = await self.db.fetchrow(
            f"""
            SELECT * FROM {self.table_name}
            WHERE id = $1 AND tenant_id = $2
            """,
            id, tenant_id
        )
        
        if not row:
            return None
        
        return self.model_class(**row)
    
    async def find_all(
        self,
        filters: dict = None,
        limit: int = 100,
        offset: int = 0
    ) -> List[T]:
        """
        Find all records within current tenant.
        """
        tenant_id = get_current_tenant_id()
        
        # Build query with tenant filter always first
        query = f"SELECT * FROM {self.table_name} WHERE tenant_id = $1"
        params = [tenant_id]
        param_idx = 2
        
        if filters:
            # Column names can't be bound as parameters: keys must come
            # from an allow-list of known columns, never from user input
            for key, value in filters.items():
                query += f" AND {key} = ${param_idx}"
                params.append(value)
                param_idx += 1
        
        query += f" LIMIT ${param_idx} OFFSET ${param_idx + 1}"
        params.extend([limit, offset])
        
        rows = await self.db.fetch(query, *params)
        return [self.model_class(**row) for row in rows]
    
    async def create(self, data: dict) -> T:
        """
        Create record within current tenant.
        """
        tenant_id = get_current_tenant_id()
        
        # Always set tenant_id
        data["tenant_id"] = tenant_id
        
        columns = ", ".join(data.keys())
        placeholders = ", ".join(f"${i+1}" for i in range(len(data)))
        
        row = await self.db.fetchrow(
            f"""
            INSERT INTO {self.table_name} ({columns})
            VALUES ({placeholders})
            RETURNING *
            """,
            *data.values()
        )
        
        return self.model_class(**row)
    
    async def update(self, id: str, data: dict) -> Optional[T]:
        """
        Update record within current tenant.
        """
        tenant_id = get_current_tenant_id()
        
        # Remove tenant_id from update data (can't change tenant)
        data.pop("tenant_id", None)
        
        set_clauses = ", ".join(
            f"{key} = ${i+1}" for i, key in enumerate(data.keys())
        )
        
        row = await self.db.fetchrow(
            f"""
            UPDATE {self.table_name}
            SET {set_clauses}
            WHERE id = ${len(data)+1} AND tenant_id = ${len(data)+2}
            RETURNING *
            """,
            *data.values(), id, tenant_id
        )
        
        if not row:
            return None
        
        return self.model_class(**row)
    
    async def delete(self, id: str) -> bool:
        """
        Delete record within current tenant.
        """
        tenant_id = get_current_tenant_id()
        
        result = await self.db.execute(
            f"""
            DELETE FROM {self.table_name}
            WHERE id = $1 AND tenant_id = $2
            """,
            id, tenant_id
        )
        
        return result.rowcount > 0
    
    async def count(self, filters: dict = None) -> int:
        """
        Count records within current tenant.
        """
        tenant_id = get_current_tenant_id()
        
        query = f"SELECT COUNT(*) FROM {self.table_name} WHERE tenant_id = $1"
        params = [tenant_id]
        
        if filters:
            for i, (key, value) in enumerate(filters.items()):
                query += f" AND {key} = ${i+2}"
                params.append(value)
        
        result = await self.db.fetchrow(query, *params)
        return result["count"]


# Example usage
@dataclass
class User:
    id: str
    tenant_id: str
    email: str
    name: str
    role: str


class UserRepository(TenantAwareRepository[User]):
    def __init__(self, db):
        super().__init__(db, "users", User)
    
    async def find_by_email(self, email: str) -> Optional[User]:
        """Custom query - still tenant-scoped."""
        tenant_id = get_current_tenant_id()
        
        row = await self.db.fetchrow(
            """
            SELECT * FROM users
            WHERE email = $1 AND tenant_id = $2
            """,
            email, tenant_id
        )
        
        if not row:
            return None
        
        return User(**row)
Your turn · checkpoint 3not answered

The repository already filters: is a second layer worth it?

Every query in the repository adds WHERE tenant_id = $n. Your team asks why you would also make the database filter rows. What does the repository not protect against, and what does the extra layer cost you?

Think it through, or jot it down. Then compare.

One strong answer

  • The repository only protects queries that go through it. A raw query, a new endpoint or a hand-written report that forgets the filter returns every tenant's rows.
  • A database-enforced filter applies to every query on the table, however it was written, so one forgotten clause stops being a breach.
  • The cost: every connection must carry the current tenant, set when it's borrowed and cleared before it goes back to the pool, or the filter uses the wrong tenant.

4.2 Database-Level Row Security (PostgreSQL RLS)#

pythonRLSConnectionManager · 183 lines
# tenant_isolation/database_rls.py

"""
PostgreSQL Row-Level Security setup for multi-tenant isolation.

RLS provides database-enforced tenant isolation - even if
application code has a bug, the database won't return wrong data.
"""

RLS_SETUP_SQL = """
-- =============================================================================
-- ROW-LEVEL SECURITY SETUP
-- =============================================================================

-- Enable RLS on tenant tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Force RLS even for table owners (important!)
ALTER TABLE users FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;


-- =============================================================================
-- TENANT CONTEXT FUNCTIONS
-- =============================================================================

-- Function to get current tenant from session variable
CREATE OR REPLACE FUNCTION current_tenant_id() 
RETURNS TEXT AS $$
BEGIN
    RETURN current_setting('app.current_tenant', true);
END;
$$ LANGUAGE plpgsql STABLE;


-- =============================================================================
-- ROW-LEVEL SECURITY POLICIES
-- =============================================================================

-- Users table policies
CREATE POLICY tenant_isolation_users ON users
    FOR ALL
    TO application_role
    USING (tenant_id = current_tenant_id())
    WITH CHECK (tenant_id = current_tenant_id());

-- Orders table policies  
CREATE POLICY tenant_isolation_orders ON orders
    FOR ALL
    TO application_role
    USING (tenant_id = current_tenant_id())
    WITH CHECK (tenant_id = current_tenant_id());

-- Documents table policies
CREATE POLICY tenant_isolation_documents ON documents
    FOR ALL
    TO application_role
    USING (tenant_id = current_tenant_id())
    WITH CHECK (tenant_id = current_tenant_id());


-- =============================================================================
-- ADMIN BYPASS POLICY (for support/admin operations)
-- =============================================================================

-- Admin role can see all data (use with extreme caution!)
CREATE POLICY admin_bypass_users ON users
    FOR ALL
    TO admin_role
    USING (true)
    WITH CHECK (true);

-- Note: Admin access should be heavily audited
"""


class RLSConnectionManager:
    """
    Manages database connections with RLS tenant context.
    
    Sets the tenant context on each connection before use.
    """
    
    def __init__(self, pool):
        self.pool = pool
    
    async def get_connection(self):
        """
        Get a connection with tenant context set.
        """
        conn = await self.pool.acquire()
        
        # Set tenant context for RLS. SET can't take bind parameters,
        # so use set_config() with a parameter (never an f-string: injection)
        tenant_id = get_current_tenant_id()
        await conn.execute(
            "SELECT set_config('app.current_tenant', $1, false)",
            tenant_id
        )
        
        return conn
    
    async def release_connection(self, conn):
        """
        Release connection and clear tenant context.
        """
        # Clear tenant context before returning to pool
        await conn.execute("RESET app.current_tenant")
        await self.pool.release(conn)


class TenantAwareConnectionPool:
    """
    Connection pool that automatically sets tenant context.
    
    Usage:
        async with await pool.connection() as conn:
            # tenant_id is automatically set
            result = await conn.fetch("SELECT * FROM users")
            # RLS filters to current tenant
    """
    
    def __init__(self, dsn: str):
        self.dsn = dsn
        self._pool = None
    
    async def initialize(self):
        """Create the connection pool."""
        import asyncpg
        self._pool = await asyncpg.create_pool(
            self.dsn,
            min_size=5,
            max_size=20,
            setup=self._setup_connection
        )
    
    async def _setup_connection(self, conn):
        """Called for each new connection."""
        # Connection starts with no tenant context
        pass
    
    async def connection(self):
        """Get connection context manager with tenant set."""
        return TenantConnection(self._pool)


class TenantConnection:
    """Async context manager for tenant-scoped connections."""
    
    def __init__(self, pool):
        self.pool = pool
        self.conn = None
    
    async def __aenter__(self):
        self.conn = await self.pool.acquire()
        
        # Set tenant context. is_local=false makes it session-level:
        # with true it would reset at the end of the implicit transaction,
        # i.e. right after this statement. __aexit__ clears it.
        try:
            tenant_id = get_current_tenant_id()
            await self.conn.execute(
                "SELECT set_config('app.current_tenant', $1, false)",
                tenant_id
            )
        except TenantContextError:
            # No tenant context - queries will fail RLS
            # This is intentional - forces proper context
            pass
        
        return self.conn
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self.conn:
            # Clear tenant context
            await self.conn.execute(
                "SELECT set_config('app.current_tenant', '', false)"
            )
            await self.pool.release(self.conn)
        return False

This wrapper sets the tenant for the whole session and must clear it before the connection goes back to the pool. The other safe option is to scope it to one transaction: BEGIN; SET LOCAL app.current_tenant = '...' (or set_config(..., true) inside that explicit transaction). PostgreSQL resets it at COMMIT or ROLLBACK, so there is no cleanup step to forget. It also works behind a pooler in transaction mode, such as PgBouncer, which does not support session-level SET. The Week 9 capstone uses SET LOCAL. Either way, a connection must never reach the next request with a tenant still set.

ExplainerTenant context and row-level security

The verified JWT's tenant_id is stored in a ContextVar, the repository adds the tenant filter and the connection sets app.current_tenant, so only acme's rows come back. RLS still filters a raw query that forgot the filter, and a pooled connection must reset app.current_tenant before release or the next request inherits that tenant.

Quick check3 questions · +5 XP each

Row-level security

  1. Q1/3 A new engineer writes a raw SELECT * FROM orders WHERE status = 'open' and forgets the tenant filter. RLS policies are enabled on orders. What does Tenant A's request get back?

    Answer & explanation

    Answer: B

    RLS adds tenant_id = current_tenant_id() to every query on the table, for reads (USING) and writes (WITH CHECK). That is why it is defense in depth behind application filtering. It does not reject the query or care how the query was built; it silently filters the rows.

  2. Q2/3 Why does the setup run FORCE ROW LEVEL SECURITY in addition to ENABLE?

    Answer & explanation

    Answer: D

    By default the table's owner is not subject to its RLS policies. If the app ever connects as that owner, isolation silently disappears. FORCE closes that gap. Which commands a policy covers is set by the policy itself (FOR ALL here), not by FORCE.

  3. Q3/3 Why must the connection wrapper clear app.current_tenant before releasing a connection back to the pool?

    Answer & explanation

    Answer: A

    Pooled connections are reused across requests and tenants. A leftover tenant setting means the next request's queries run as the wrong tenant: a cross-tenant leak. Resetting before release ensures every borrower starts with no tenant, so a missing context fails RLS instead of leaking.

Chapter 5: Schema-Per-Tenant Implementation#

5.1 Dynamic Schema Management#

pythonSchemaPerTenantManager · 175 lines
# tenant_isolation/schema_manager.py

"""
Schema-per-tenant implementation.

Each tenant gets their own database schema with identical structure.
Provides stronger isolation than shared tables.
"""

import logging
import re
from typing import List

logger = logging.getLogger(__name__)


class SchemaPerTenantManager:
    """
    Manages schema-per-tenant database architecture.
    """
    
    def __init__(self, admin_pool, schema_prefix: str = "tenant_"):
        self.admin_pool = admin_pool
        self.schema_prefix = schema_prefix
    
    async def create_tenant_schema(self, tenant_id: str):
        """
        Create a new schema for a tenant.
        
        Called during tenant onboarding.
        """
        # Identifiers can't be bound as parameters: validate before interpolating
        if not re.fullmatch(r"[a-z0-9_]{1,50}", tenant_id):
            raise ValueError(f"Invalid tenant_id: {tenant_id!r}")
        schema_name = f"{self.schema_prefix}{tenant_id}"
        
        async with self.admin_pool.acquire() as conn:
            # Create schema
            await conn.execute(f"CREATE SCHEMA IF NOT EXISTS {schema_name}")
            
            # Create tables in schema
            await self._create_schema_tables(conn, schema_name)
            
            # Grant permissions
            await conn.execute(f"""
                GRANT USAGE ON SCHEMA {schema_name} TO app_user
            """)
            await conn.execute(f"""
                GRANT ALL ON ALL TABLES IN SCHEMA {schema_name} TO app_user
            """)
            
            logger.info(f"Created schema for tenant: {tenant_id}")
    
    async def _create_schema_tables(self, conn, schema_name: str):
        """Create standard tables in tenant schema."""
        
        await conn.execute(f"""
            CREATE TABLE {schema_name}.users (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                email VARCHAR(255) UNIQUE NOT NULL,
                name VARCHAR(255) NOT NULL,
                role VARCHAR(50) DEFAULT 'member',
                created_at TIMESTAMP DEFAULT NOW(),
                updated_at TIMESTAMP DEFAULT NOW()
            )
        """)
        
        await conn.execute(f"""
            CREATE TABLE {schema_name}.documents (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                title VARCHAR(500) NOT NULL,
                content TEXT,
                created_by UUID REFERENCES {schema_name}.users(id),
                created_at TIMESTAMP DEFAULT NOW(),
                updated_at TIMESTAMP DEFAULT NOW()
            )
        """)
        
        await conn.execute(f"""
            CREATE TABLE {schema_name}.settings (
                key VARCHAR(255) PRIMARY KEY,
                value JSONB,
                updated_at TIMESTAMP DEFAULT NOW()
            )
        """)
    
    async def delete_tenant_schema(self, tenant_id: str):
        """
        Delete a tenant's schema.
        
        Called during tenant offboarding. Requires confirmation!
        """
        schema_name = f"{self.schema_prefix}{tenant_id}"
        
        async with self.admin_pool.acquire() as conn:
            # CASCADE drops all objects in schema
            await conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
            
            logger.info(f"Deleted schema for tenant: {tenant_id}")
    
    async def get_all_tenant_schemas(self) -> List[str]:
        """Get list of all tenant schemas."""
        
        async with self.admin_pool.acquire() as conn:
            rows = await conn.fetch(f"""
                SELECT schema_name 
                FROM information_schema.schemata
                WHERE schema_name LIKE '{self.schema_prefix}%'
            """)
            
            return [row["schema_name"] for row in rows]
    
    async def run_migration(self, migration_sql: str):
        """
        Run migration across all tenant schemas.
        """
        schemas = await self.get_all_tenant_schemas()
        
        async with self.admin_pool.acquire() as conn:
            for schema in schemas:
                try:
                    # Set search path to tenant schema
                    await conn.execute(f"SET search_path TO {schema}")
                    
                    # Run migration
                    await conn.execute(migration_sql)
                    
                    logger.info(f"Migration complete for schema: {schema}")
                    
                except Exception as e:
                    logger.error(f"Migration failed for {schema}: {e}")
                    raise
        
        logger.info(f"Migration complete for {len(schemas)} schemas")


class SchemaRoutingConnectionPool:
    """
    Connection pool that routes to tenant-specific schema.
    """
    
    def __init__(self, pool, schema_prefix: str = "tenant_"):
        self.pool = pool
        self.schema_prefix = schema_prefix
    
    async def connection(self):
        """Get connection with schema routing."""
        return SchemaRoutedConnection(self.pool, self.schema_prefix)


class SchemaRoutedConnection:
    """Connection that automatically routes to tenant schema."""
    
    def __init__(self, pool, schema_prefix: str):
        self.pool = pool
        self.schema_prefix = schema_prefix
        self.conn = None
    
    async def __aenter__(self):
        self.conn = await self.pool.acquire()
        
        # Set search path to tenant schema
        tenant_id = get_current_tenant_id()
        schema_name = f"{self.schema_prefix}{tenant_id}"
        
        await self.conn.execute(f"SET search_path TO {schema_name}, public")
        
        return self.conn
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self.conn:
            # Reset search path
            await self.conn.execute("SET search_path TO public")
            await self.pool.release(self.conn)
        return False
Your turn · checkpoint 4not answered

Estimate: connections for a dedicated tier

Each dedicated tenant gets its own pool of up to 10 connections in every app process. Assume 1,000 enterprise tenants and 20 app processes, each of which eventually serves every tenant. How many database connections can the fleet open, and how does that compare with one shared pool of 20 per process?

Think it through, or jot it down. Then compare.

One strong answer

  • Dedicated: 1,000 tenants × 10 × 20 processes = 200,000 connections at most.
  • Shared: 20 × 20 processes = 400 connections, whatever the tenant count.
  • So per-tenant pools multiply by tenants and by processes. That's why connection management is listed as a cost of database per tenant, and why that pattern stays at hundreds of tenants.

Chapter 6: Database-Per-Tenant Implementation#

6.1 Database Provisioning#

pythonTenantDatabase · 205 lines
# tenant_isolation/database_per_tenant.py

"""
Database-per-tenant implementation.

Each tenant gets their own database instance.
Provides strongest isolation but highest operational complexity.
"""

from dataclasses import dataclass
from typing import Dict, Optional
import logging

logger = logging.getLogger(__name__)


@dataclass
class TenantDatabase:
    """Configuration for a tenant's database."""
    tenant_id: str
    host: str
    port: int
    database: str
    username: str
    password: str  # Should be encrypted/from secrets manager
    ssl_mode: str = "require"
    
    @property
    def connection_string(self) -> str:
        return (
            f"postgresql://{self.username}:{self.password}@"
            f"{self.host}:{self.port}/{self.database}"
            f"?sslmode={self.ssl_mode}"
        )


class DatabasePerTenantManager:
    """
    Manages database-per-tenant architecture.
    
    In practice, this often integrates with:
    - AWS RDS for database provisioning
    - Terraform for infrastructure
    - Secrets Manager for credentials
    """
    
    def __init__(self, db_cluster_endpoint: str, admin_credentials: dict):
        self.cluster_endpoint = db_cluster_endpoint
        self.admin_creds = admin_credentials
        self.tenant_pools: Dict[str, any] = {}
    
    async def provision_tenant_database(
        self,
        tenant_id: str,
        tier: str = "standard"
    ) -> TenantDatabase:
        """
        Provision a new database for a tenant.
        
        In production, this might:
        - Call AWS RDS API to create database
        - Use Terraform to provision infrastructure
        - Set up replication, backups, etc.
        """
        
        import asyncpg
        import secrets
        
        database_name = f"tenant_{tenant_id}"
        username = f"user_{tenant_id}"
        password = secrets.token_urlsafe(32)
        
        # Connect as admin to create database
        admin_conn = await asyncpg.connect(
            host=self.cluster_endpoint,
            user=self.admin_creds["username"],
            password=self.admin_creds["password"],
            database="postgres"
        )
        
        try:
            # Create user first: it will own the database. On PostgreSQL 15+
            # only the owner can create tables in the new database's public
            # schema (the admin role must be able to SET ROLE to the owner)
            await admin_conn.execute(f"""
                CREATE USER {username} WITH PASSWORD '{password}'
            """)
            
            # Create database
            await admin_conn.execute(f"""
                CREATE DATABASE {database_name}
                WITH OWNER {username} ENCODING 'UTF8'
            """)
            
            # Grant permissions
            await admin_conn.execute(f"""
                GRANT ALL PRIVILEGES ON DATABASE {database_name} TO {username}
            """)
            
            logger.info(f"Provisioned database for tenant: {tenant_id}")
            
        finally:
            await admin_conn.close()
        
        # Connect to new database to set up schema
        tenant_conn = await asyncpg.connect(
            host=self.cluster_endpoint,
            user=username,
            password=password,
            database=database_name
        )
        
        try:
            await self._create_tenant_schema(tenant_conn)
        finally:
            await tenant_conn.close()
        
        tenant_db = TenantDatabase(
            tenant_id=tenant_id,
            host=self.cluster_endpoint,
            port=5432,
            database=database_name,
            username=username,
            password=password
        )
        
        # Store credentials in secrets manager (not shown)
        await self._store_credentials(tenant_id, tenant_db)
        
        return tenant_db
    
    async def _create_tenant_schema(self, conn):
        """Create tables in tenant database."""
        
        await conn.execute("""
            CREATE TABLE users (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                email VARCHAR(255) UNIQUE NOT NULL,
                name VARCHAR(255) NOT NULL,
                role VARCHAR(50) DEFAULT 'member',
                created_at TIMESTAMP DEFAULT NOW()
            )
        """)
        
        await conn.execute("""
            CREATE TABLE documents (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                title VARCHAR(500) NOT NULL,
                content TEXT,
                created_by UUID REFERENCES users(id),
                created_at TIMESTAMP DEFAULT NOW()
            )
        """)
    
    async def _store_credentials(self, tenant_id: str, db: TenantDatabase):
        """Store credentials in secrets manager."""
        # In production: AWS Secrets Manager, HashiCorp Vault, etc.
        pass
    
    async def get_tenant_pool(self, tenant_id: str):
        """
        Get connection pool for a specific tenant.
        
        Pools are created lazily and cached.
        """
        import asyncpg
        
        if tenant_id in self.tenant_pools:
            return self.tenant_pools[tenant_id]
        
        # Load tenant database config
        tenant_db = await self._load_tenant_config(tenant_id)
        
        if not tenant_db:
            raise ValueError(f"No database configured for tenant: {tenant_id}")
        
        # Create pool
        pool = await asyncpg.create_pool(
            tenant_db.connection_string,
            min_size=2,
            max_size=10
        )
        
        self.tenant_pools[tenant_id] = pool
        return pool
    
    async def _load_tenant_config(self, tenant_id: str) -> Optional[TenantDatabase]:
        """Load tenant database configuration."""
        # In production: Load from secrets manager
        pass


class TenantDatabaseRouter:
    """
    Routes database connections to the correct tenant database.
    """
    
    def __init__(self, db_manager: DatabasePerTenantManager):
        self.db_manager = db_manager
    
    async def connection(self):
        """Get connection to current tenant's database."""
        tenant_id = get_current_tenant_id()
        pool = await self.db_manager.get_tenant_pool(tenant_id)
        return pool.acquire()
WorksheetConnections for the Asana-style SaaS (tier split and fleet size are assumptions)
Value
Organizations (from the sample interview) 50,000
Share on the enterprise tier with a database each (assumed) 2% → 1,000 databases
Pool per tenant per app process (from get_tenant_pool) up to 10 connections
App processes (assumed) 20
Dedicated tier, worst case 1,000 × 10 × 20 = 200,000 connections
Shared tier, one pool of up to 20 per process (from the RLS pool) 20 × 20 = 400 connections for 49,000 tenants

Conclusion: 2% of the tenants could hold 99.8% of the connections. A dedicated tier only works if it stays small, which is why the comparison table puts database per tenant at hundreds of tenants, not thousands.

Chapter 7: Tenant-Aware Services Pattern#

7.1 Complete Service Implementation#

pythonCreateDocumentRequest · 239 lines
# tenant_isolation/service.py

"""
Complete example of a tenant-aware service.

This shows how all the pieces fit together:
- Middleware extracts tenant
- Service uses tenant-aware repository
- Caching is tenant-scoped
- Logging includes tenant context
"""

from fastapi import FastAPI, Depends, HTTPException
from typing import List, Optional
from dataclasses import dataclass
from datetime import datetime, timezone
import logging

logger = logging.getLogger(__name__)


@dataclass
class CreateDocumentRequest:
    title: str
    content: str


@dataclass
class Document:
    id: str
    tenant_id: str
    title: str
    content: str
    created_by: str
    created_at: datetime


class DocumentService:
    """
    Tenant-aware document service.
    
    All operations are automatically scoped to current tenant.
    """
    
    def __init__(self, document_repo, user_repo, cache, event_publisher,
                 search_client=None):
        self.documents = document_repo
        self.users = user_repo
        self.cache = cache
        self.events = event_publisher
        self.search_client = search_client
    
    @requires_tenant_async
    async def create_document(
        self,
        request: CreateDocumentRequest,
        created_by_user_id: str
    ) -> Document:
        """
        Create a new document in current tenant.
        """
        tenant = get_current_tenant()
        
        # Validate user exists in this tenant
        user = await self.users.find_by_id(created_by_user_id)
        if not user:
            raise ValueError("User not found")
        
        # Create document
        document = await self.documents.create({
            "title": request.title,
            "content": request.content,
            "created_by": created_by_user_id,
            "created_at": datetime.now(timezone.utc)
        })
        
        # Invalidate cache
        await self._invalidate_document_cache()
        
        # Publish event
        await self.events.publish(
            topic="documents",
            event={
                "type": "document.created",
                "tenant_id": tenant.tenant_id,
                "document_id": document.id,
                "created_by": created_by_user_id
            }
        )
        
        logger.info(
            "Document created",
            extra={
                "tenant_id": tenant.tenant_id,
                "document_id": document.id,
                "user_id": created_by_user_id
            }
        )
        
        return document
    
    @requires_tenant_async
    async def get_document(self, document_id: str) -> Optional[Document]:
        """
        Get document by ID within current tenant.
        """
        tenant = get_current_tenant()
        cache_key = f"tenant:{tenant.tenant_id}:doc:{document_id}"
        
        # Check cache
        cached = await self.cache.get(cache_key)
        if cached:
            return Document(**cached)
        
        # Query database
        document = await self.documents.find_by_id(document_id)
        
        if document:
            # Cache for 5 minutes
            await self.cache.set(cache_key, document.__dict__, ttl=300)
        
        return document
    
    @requires_tenant_async
    async def list_documents(
        self,
        limit: int = 50,
        offset: int = 0
    ) -> List[Document]:
        """
        List documents in current tenant.
        """
        return await self.documents.find_all(
            limit=limit,
            offset=offset
        )
    
    @requires_tenant_async
    async def search_documents(self, query: str) -> List[Document]:
        """
        Search documents within current tenant.
        
        Search index is tenant-scoped.
        """
        tenant = get_current_tenant()
        
        # Search in tenant-scoped index
        results = await self.search_client.search(
            index=f"documents_{tenant.tenant_id}",
            query=query
        )
        
        return [Document(**r) for r in results]
    
    @requires_tenant_async
    async def delete_document(self, document_id: str) -> bool:
        """
        Delete document within current tenant.
        """
        tenant = get_current_tenant()
        
        # Delete from database
        deleted = await self.documents.delete(document_id)
        
        if deleted:
            # Invalidate cache
            cache_key = f"tenant:{tenant.tenant_id}:doc:{document_id}"
            await self.cache.delete(cache_key)
            
            # Publish event
            await self.events.publish(
                topic="documents",
                event={
                    "type": "document.deleted",
                    "tenant_id": tenant.tenant_id,
                    "document_id": document_id
                }
            )
        
        return deleted
    
    async def _invalidate_document_cache(self):
        """Invalidate document list caches for current tenant."""
        tenant = get_current_tenant()
        pattern = f"tenant:{tenant.tenant_id}:docs:*"
        await self.cache.delete_pattern(pattern)


# FastAPI router with tenant middleware

app = FastAPI()

# Dependency to get document service
async def get_document_service() -> DocumentService:
    # In production, use dependency injection
    pass


@app.post("/api/documents")
async def create_document(
    request: CreateDocumentRequest,
    service: DocumentService = Depends(get_document_service)
):
    """
    Create a new document.
    
    Tenant context is set by middleware before this runs.
    """
    # Get current user from auth context
    user_id = get_current_user_id()
    
    document = await service.create_document(request, user_id)
    
    return {"id": document.id, "title": document.title}


@app.get("/api/documents/{document_id}")
async def get_document(
    document_id: str,
    service: DocumentService = Depends(get_document_service)
):
    """Get a document by ID."""
    document = await service.get_document(document_id)
    
    if not document:
        raise HTTPException(status_code=404, detail="Document not found")
    
    return document


@app.get("/api/documents")
async def list_documents(
    limit: int = 50,
    offset: int = 0,
    service: DocumentService = Depends(get_document_service)
):
    """List documents in current tenant."""
    documents = await service.list_documents(limit=limit, offset=offset)
    return {"documents": documents, "count": len(documents)}

7.2 Tenant-Aware Event Processing#

pythonTenantEvent · 132 lines
# tenant_isolation/events.py

"""
Tenant-aware event processing.

Events in multi-tenant systems must carry tenant context
so consumers can process them correctly.
"""

from dataclasses import dataclass
from typing import Dict, Any, Callable
from datetime import datetime, timezone
import json


@dataclass
class TenantEvent:
    """
    Event with tenant context.
    
    All events must include tenant_id for proper routing and processing.
    """
    event_id: str
    event_type: str
    tenant_id: str
    timestamp: datetime
    payload: Dict[str, Any]
    
    def to_json(self) -> str:
        return json.dumps({
            "event_id": self.event_id,
            "event_type": self.event_type,
            "tenant_id": self.tenant_id,
            "timestamp": self.timestamp.isoformat(),
            "payload": self.payload
        })
    
    @classmethod
    def from_json(cls, data: str) -> 'TenantEvent':
        d = json.loads(data)
        return cls(
            event_id=d["event_id"],
            event_type=d["event_type"],
            tenant_id=d["tenant_id"],
            timestamp=datetime.fromisoformat(d["timestamp"]),
            payload=d["payload"]
        )


class TenantEventPublisher:
    """
    Publishes events with tenant context.
    """
    
    def __init__(self, kafka_producer):
        self.producer = kafka_producer
    
    async def publish(self, topic: str, event: Dict[str, Any]):
        """
        Publish event with current tenant context.
        """
        import uuid
        
        tenant = get_current_tenant()
        
        tenant_event = TenantEvent(
            event_id=str(uuid.uuid4()),
            event_type=event.get("type", "unknown"),
            tenant_id=tenant.tenant_id,
            timestamp=datetime.now(timezone.utc),
            payload=event
        )
        
        # Use tenant_id as partition key for ordering
        await self.producer.send(
            topic=topic,
            key=tenant.tenant_id,
            value=tenant_event.to_json()
        )


class TenantEventConsumer:
    """
    Consumes events and sets tenant context before processing.
    """
    
    def __init__(self, kafka_consumer, tenant_service):
        self.consumer = kafka_consumer
        self.tenant_service = tenant_service
        self.handlers: Dict[str, Callable] = {}
    
    def register_handler(self, event_type: str, handler: Callable):
        """Register handler for event type."""
        self.handlers[event_type] = handler
    
    async def start(self):
        """Start consuming events."""
        async for message in self.consumer:
            await self._process_message(message)
    
    async def _process_message(self, message):
        """Process a single message with tenant context."""
        
        event = TenantEvent.from_json(message.value)
        
        # Load tenant context
        tenant_ctx = await self.tenant_service.get_tenant_context(
            event.tenant_id
        )
        
        if not tenant_ctx:
            logger.error(f"Unknown tenant: {event.tenant_id}")
            return
        
        # Set tenant context and process
        with tenant_scope(tenant_ctx):
            handler = self.handlers.get(event.event_type)
            
            if handler:
                try:
                    await handler(event)
                except Exception as e:
                    logger.error(
                        f"Event processing failed: {e}",
                        extra={
                            "tenant_id": event.tenant_id,
                            "event_id": event.event_id,
                            "event_type": event.event_type
                        }
                    )
            else:
                logger.warning(f"No handler for event type: {event.event_type}")
Your turn · checkpoint 5not answered

What breaks when a job forgets its tenant?

A request for acme queues send_welcome_email(user_id). A worker in another process picks it up and runs User.get(user_id). What tenant is the worker in, what can go wrong, and what should the job's message carry?

Think it through, or jot it down. Then compare.

One strong answer

  • No tenant at all. The request's context variable lives in the process that handled the request; it doesn't travel with the message.
  • What goes wrong: the query either fails for lack of a tenant or, since IDs repeat across tenants, finds another tenant's user and emails them.
  • The fix: the message carries tenant_id, and the worker loads that tenant and runs the job inside tenant_scope before touching any data.

7.3 Tenant-Aware Background Jobs#

pythontenant_task · 108 lines
# tenant_isolation/jobs.py

"""
Tenant-aware background job processing.

All background jobs must run within tenant context.
"""

from celery import Celery
from functools import wraps


celery_app = Celery('tasks')


def tenant_task(func):
    """
    Decorator for tenant-aware Celery tasks.
    
    Usage:
        @celery_app.task
        @tenant_task
        def process_document(document_id):
            # Runs in tenant context
            doc = document_repo.find_by_id(document_id)
    """
    @wraps(func)
    def wrapper(tenant_id: str, *args, **kwargs):
        # Load tenant context
        tenant_ctx = load_tenant_sync(tenant_id)
        
        if not tenant_ctx:
            raise ValueError(f"Unknown tenant: {tenant_id}")
        
        # Set context and run
        with tenant_scope(tenant_ctx):
            return func(*args, **kwargs)
    
    return wrapper


# Example tasks

@celery_app.task
@tenant_task
def send_document_notification(document_id: str, user_ids: list):
    """
    Send notifications about a document.
    
    Note: callers pass tenant_id as the first argument; @tenant_task consumes it
    """
    document = document_repo.find_by_id(document_id)
    
    if not document:
        return
    
    for user_id in user_ids:
        user = user_repo.find_by_id(user_id)
        if user:
            notification_service.send(
                user=user,
                template="document_shared",
                context={"document": document}
            )


@celery_app.task
@tenant_task
def generate_tenant_report():
    """
    Generate usage report for current tenant.
    """
    tenant = get_current_tenant()
    
    stats = {
        "tenant_id": tenant.tenant_id,
        "user_count": user_repo.count(),
        "document_count": document_repo.count(),
        "storage_used": storage_service.get_usage(),
    }
    
    report_service.generate(stats)


# Scheduling tenant jobs
class TenantJobScheduler:
    """
    Schedule jobs for all tenants or specific tenant.
    """
    
    def __init__(self, tenant_service):
        self.tenant_service = tenant_service
    
    async def schedule_for_all_tenants(self, task, *args, **kwargs):
        """
        Schedule a task to run for each tenant.
        
        Useful for nightly batch jobs.
        """
        tenants = await self.tenant_service.get_all_active_tenants()
        
        for tenant in tenants:
            # Include tenant_id as first argument
            task.delay(tenant.tenant_id, *args, **kwargs)
    
    async def schedule_for_tenant(self, tenant_id: str, task, *args, **kwargs):
        """Schedule a task for specific tenant."""
        task.delay(tenant_id, *args, **kwargs)
Quick check3 questions · +5 XP each

Schema, database and service-level isolation

  1. Q1/3 You run run_migration across 10,000 tenant schemas and it fails on schema number 4,000. What state is the system in?

    Answer & explanation

    Answer: C

    The loop migrates schemas one by one and re-raises on the first failure, so the earlier schemas keep the new structure and the later ones do not. That mixed state is why "migrations more complex" is listed as a cost of schema-per-tenant; nothing wraps the whole run in one transaction.

  2. Q2/3 With database per tenant, every tenant gets its own lazily created pool of up to 10 connections. Roughly how many database connections can 500 active tenants on one cluster open?

    Answer & explanation

    Answer: D

    Each tenant pool is separate, so the maximum is 500 x 10 = 5,000 connections. That is why "connection pool per tenant" and higher operational complexity appear as the cons of this pattern; pools do not share connections across databases.

  3. Q3/3 Why does @tenant_task take tenant_id as an explicit task argument instead of reading the caller's tenant context?

    Answer & explanation

    Answer: B

    The ContextVar only lives in the process handling the original request. The job's message must carry tenant_id, and the worker rebuilds the context with tenant_scope before running. Without it the job has no tenant, which is "Mistake 5: Background Jobs Without Tenant Context".

Part III · Stage 3 of 4 · 4 min

Part III: Real-World Application#

Chapter 8: Case Studies#

8.1 Slack's Multi-Tenancy#

Slack's isolation architecture
Challenge
  • Many enterprise customers
  • Strict data isolation required
  • Real-time messaging at scale
  • Some customers need dedicated infrastructure
Solution: HYBRID APPROACH
Standard Tenants (Shared Infrastructure) Enterprise Grid (Enhanced Isolation)
Logical isolation with workspace_id Dedicated Vitess shards
All messages in shared Vitess cluster Isolated search clusters
Row-level filtering in application Customer-managed encryption keys
Shared search infrastructure Can be in customer's preferred region

(The tier split is illustrative; Slack does not publish per-tier infrastructure details.)

Key innovations
  • WORKSPACE AS TENANT BOUNDARY — Clear isolation unit
  • CHANNEL-BASED PARTITIONING — Data sharded by channel for performance
  • ENTERPRISE KEY MANAGEMENT (EKM) — Customer controls the encryption keys and can revoke Slack's access to their data
  • SLACK CONNECT — Controlled cross-tenant communication; explicit consent from both sides

Lesson: Offer different isolation levels at different price points

8.2 Salesforce Multi-Tenancy#

Salesforce's architecture
Scale
  • A very large customer base (well into the tens of thousands)
  • Shared infrastructure
  • Custom objects and fields per tenant
  • 20+ years of evolution
Architecture — Salesforce multi-tenant
  • Application Layer: Stateless app servers · Tenant context from session · All queries include OrgId
  • Metadata Layer: Custom objects stored as metadata · Fields defined in metadata tables · Runtime schema assembly per tenant
  • Database Layer: Large relational database clusters (historically Oracle) · OrgId in every table · Massive indexes on OrgId · Data and metadata tables separate
Key innovations

GOVERNOR LIMITS:

  • CPU time per transaction: 10 seconds
  • SOQL queries per transaction: 100
  • Records retrieved: 50,000
  • API calls per day: Based on licenses
  • Purpose: Prevent noisy neighbors

METADATA-DRIVEN CUSTOMIZATION:

  • Custom objects don't create real tables
  • One wide generic data table (flex columns) stores custom field data; pivot tables provide indexes and uniqueness
  • Runtime query assembly
  • Allows deep per-tenant customization

TRUST.SALESFORCE.COM:

  • Real-time system status
  • Performance metrics per instance
  • Transparency about incidents
  • Builds customer trust

Lesson: Strict resource limits enable massive multi-tenancy

8.3 AWS Multi-Tenancy Patterns#

AWS's guidance on multi-tenancy

AWS categorizes into "Silo" vs "Pool" models:

SILO MODEL (Dedicated Resources)
  • Separate AWS accounts per tenant
  • VPC isolation
  • Dedicated RDS instances
  • Independent scaling
  • Maximum isolation
  • Higher cost, simpler compliance
POOL MODEL (Shared Resources)
  • Single AWS account
  • Shared infrastructure
  • Tenant_id filtering
  • More efficient resource use
  • Lower cost, more complex
  • Requires careful design
BRIDGE MODEL (Hybrid)
  • Shared compute (ECS/EKS)
  • Isolated data stores
  • Tenant-specific encryption keys
  • Balance of cost and isolation
  • Common for SaaS
AWS Recommendations
  • Use AWS Organizations for account management
  • Use Resource Access Manager for sharing
  • Use KMS with per-tenant keys
  • Use CloudWatch with tenant dimensions
  • Use IAM policies for access control

Chapter 9: Common Mistakes#

9.1 Tenant Isolation Mistakes#

Multi-tenancy anti-patterns
Mistake 1: Missing WHERE Clause

Wrong:

python2 lines
# Forgot tenant filter!
users = db.query("SELECT * FROM users WHERE role = 'admin'")

Problem: Returns admins from ALL tenants. Data breach waiting to happen.

Right:

python3 lines
# Always use tenant-aware repository
users = await user_repo.find_all(filters={"role": "admin"})
# Repository adds tenant_id automatically
Mistake 2: Trusting Client-Provided Tenant ID

Wrong:

python2 lines
tenant_id = request.headers.get("X-Tenant-ID")
# Trust it blindly

Problem: Attacker sets X-Tenant-ID: victim_tenant. Access other tenant's data.

Right:

python3 lines
# Derive tenant from authenticated user
tenant_id = request.user.tenant_id
# Or validate header against user's allowed tenants
Mistake 3: Shared Cache Without Tenant Prefix

Wrong:

python2 lines
cache_key = f"user:{user_id}"
cached_user = redis.get(cache_key)

Problem: User ID 123 exists in both tenants. Returns wrong tenant's user.

Right:

python3 lines
tenant_id = get_current_tenant_id()
cache_key = f"tenant:{tenant_id}:user:{user_id}"
cached_user = redis.get(cache_key)
Mistake 4: Logging PII Without Tenant Context

Wrong:

python1 line
logger.info(f"User {user.email} logged in")

Problem: No way to filter logs by tenant. Can't provide tenant-specific audit logs. Cross-tenant data in same log stream.

Right:

python7 lines
logger.info(
    "User logged in",
    extra={
        "tenant_id": get_current_tenant_id(),
        "user_id": user.id,  # Not email!
    }
)
Mistake 5: Background Jobs Without Tenant Context

Wrong:

pythonsend_welcome_email · 4 lines
@celery.task
def send_welcome_email(user_id):
    user = User.get(user_id)  # Which tenant?!
    send_email(user.email)

Problem: Job runs without tenant context. Query might fail or return wrong data.

Right:

pythonsend_welcome_email · 5 lines
@celery.task
def send_welcome_email(tenant_id, user_id):
    with tenant_scope(load_tenant(tenant_id)):
        user = user_repo.find_by_id(user_id)
        send_email(user.email)

9.2 Security Checklist#

Multi-tenant security checklist

Before Production:

Tenant identification
Tenant identification0/4 ready
  • Tenant derived from authenticated session, not headers
  • Tenant ID validated on every request
  • Admin endpoints require additional auth
  • API keys are tenant-scoped
Data access
Data access0/5 ready
  • All queries include tenant filter
  • Database has RLS enabled (PostgreSQL)
  • Cache keys include tenant prefix
  • File storage paths include tenant
  • Search indexes are tenant-scoped
Background processing
Background processing0/4 ready
  • Jobs include tenant context
  • Queues are tenant-aware
  • Scheduled tasks run in tenant scope
  • Webhooks validated before processing
Logging and monitoring
Logging and monitoring0/4 ready
  • All logs include tenant_id
  • Metrics tagged with tenant
  • Alerts can be filtered by tenant
  • No PII in logs (use IDs)
Testing
Testing0/4 ready
  • Tests run in isolated tenant
  • Cross-tenant access tests exist
  • Penetration testing for tenant isolation
  • Automated security scans
Quick check3 questions · +5 XP each

Real-world patterns and common mistakes

  1. Q1/3 You want shared compute to keep costs down, but your customers insist on separate data stores and their own encryption keys. Which AWS model fits?

    Answer & explanation

    Answer: D

    The bridge model shares compute but isolates data and uses tenant-specific keys, balancing cost and isolation; it is a common SaaS choice. Silo would also isolate compute at much higher cost, and pool (even with RLS) keeps every tenant in the same data store.

  2. Q2/3 Your cache key is user:{user_id}. Tenant B requests user 123 right after Tenant A loaded its own user 123. What happens?

    Answer & explanation

    Answer: B

    IDs can repeat across tenants, so an unprefixed key collides and serves one tenant's data to another. RLS protects the database, not the cache. The fix is a tenant prefix such as tenant:{tenant_id}:user:{user_id}.

  3. Q3/3 An attacker sends X-Tenant-ID: victim_tenant with their own valid login. What is the right defense?

    Answer & explanation

    Answer: C

    The client controls the header, so trusting it lets anyone pick a tenant. Get the tenant from the authenticated session or verify the header against it. RLS cannot help here: it enforces whatever tenant the app sets, so a spoofed tenant gets the victim's rows.

Part IV · Stage 4 of 4 · 9 min

Part IV: Interview Preparation#

Chapter 10: Interview Tips#

10.1 When to Discuss Multi-Tenancy#

Multi-tenancy interview triggers
"Design a SaaS platform for..."
  • Immediately ask about tenant count
  • Ask about enterprise requirements
  • Discuss isolation levels
"Design a system that stores user data..."
  • Ask: "Multiple organizations?"
  • If yes, tenant isolation is critical
  • Discuss data segregation
"How would you handle multiple customers?"
  • This IS a multi-tenancy question
  • Discuss isolation spectrum
  • Propose appropriate level
"Security requirements for B2B..."
  • Multi-tenancy is implicit
  • Enterprise customers want isolation
  • Compliance often requires segregation
❯ Say this

10.2 Key Phrases#

Discussing multi-tenancy in interviews
Opening

"For multi-tenancy, the key decision is the isolation level. This is a spectrum from shared tables with row-level filtering to completely dedicated infrastructure per tenant."

Shared Tables

"For a startup with SMB customers, shared tables with tenant_id filtering is cost-effective. I'd use PostgreSQL Row-Level Security for database-enforced isolation, plus application-level filtering as defense in depth."

Schema Per Tenant

"For mid-market SaaS with compliance requirements, schema-per-tenant provides stronger isolation. Each tenant gets their own schema, so a bug can't accidentally expose cross-tenant data."

Database Per Tenant

"For enterprise customers requiring strict isolation, database-per-tenant makes sense. It enables per-tenant backups, independent scaling, and meets compliance requirements. The trade-off is operational complexity and cost."

Hybrid Approach

"Many successful SaaS platforms use a tiered approach: shared tables for self-serve customers, and dedicated resources for enterprise customers who pay for isolation. Slack, for example, sells Enterprise Key Management as an add-on for its Enterprise Grid tier."

Chapter 11: Practice Problems#

Practice · problem 1

Problem 1: E-commerce SaaS Platform#

Setup: Design a multi-tenant e-commerce platform where merchants can set up online stores. You have 10,000 merchants, ranging from small shops (100 orders/month) to large retailers (100,000 orders/month).

Requirements:

  • Each merchant's data must be isolated
  • Large merchants shouldn't affect small merchants
  • Some merchants need dedicated infrastructure

Questions:

  1. What isolation level would you choose?
  2. How do you handle the 1000x variation in scale (100 vs 100,000 orders/month)?
  3. How do you migrate a merchant from shared to dedicated?
Click for hints
  • Consider hybrid approach: shared for small, dedicated for large
  • Shard by merchant_id for the shared tier
  • Design migration path from shared to dedicated
  • Use feature flags to control which tier a merchant uses

Practice · problem 2

Problem 2: Healthcare SaaS#

Setup: Design a multi-tenant EHR (Electronic Health Records) system for clinics. HIPAA compliance required.

Requirements:

  • Strong data isolation (PHI)
  • Audit logging of all access
  • Some clinics part of same hospital network

Questions:

  1. Can you use shared tables for PHI?
  2. How do you handle clinic networks (parent-child tenants)?
  3. What's your audit logging strategy?
Click for hints
  • Many EHR vendors choose database-per-tenant or stronger for PHI (HIPAA itself doesn't mandate an isolation model)
  • Consider organization hierarchy in tenant model
  • Audit logging at database level (e.g. pgAudit; triggers don't fire on SELECT, so they miss reads)
  • Encryption with per-tenant keys

Chapter 12: Sample Interview Dialogue#

Transcript
Interviewer

"Design a project management SaaS like Asana. Multiple companies will use it."

You

"Great. This is a multi-tenant system. Let me start by understanding the scale and customer profile. How many organizations are we targeting? What's the range — startups to enterprises?"

Interviewer

"Let's say 50,000 organizations, from 5-person startups to 10,000-person enterprises."

You

"That range is important. For isolation, I'd propose a tiered approach:

Standard tier Shared PostgreSQL with tenant_id in every table. I'd use Row-Level Security so the database enforces isolation. This works well for startups and SMBs — cost-effective and scales to tens of thousands of tenants.

Enterprise tier For large organizations with compliance requirements, I'd offer schema-per-tenant or dedicated database options. This provides stronger isolation guarantees and enables features like customer-managed encryption keys.

Let me draw the data model..."

Tenant model
  • tenants table
    • id — (UUID)
    • name
    • plan — (standard, enterprise)
    • isolation_level — (shared, schema, dedicated)
    • created_at
    • settings — (JSONB)
  • For shared tier, all tables have:
    • tenant_id — (indexed)
    • Composite indexes: (tenant_id, other_columns)
    • RLS policy: USING (tenant_id = current_tenant_id())
Transcript
Interviewer

"What if an enterprise customer runs a huge report that slows down the database?"

You

"Noisy neighbor problem — great question. Several defenses:

  1. Query timeouts: Limit query execution time per tenant
  2. Resource quotas: Track and limit database connections, CPU time
  3. Read replicas: Route heavy analytics to replicas
  4. For enterprise tier: Their dedicated database means they only affect themselves

For the shared tier, I'd also implement query analysis to flag expensive queries before they run."

Interviewer

"How do you handle tenant context across microservices?"

You

"Tenant context needs to propagate through the entire request path. I'd implement:

  1. API Gateway: Derives tenant from the verified JWT, strips any client-supplied X-Tenant-ID and sets its own
  2. Service mesh: Routes traffic between services, but each service must forward the tenant header itself (proxies don't propagate custom headers on their own)
  3. Each service: Middleware validates header, sets context variable
  4. Database layer: Uses context to set RLS variables

For async jobs, the message includes tenant_id, and the worker sets context before processing.

This ensures every database query, cache lookup, and log entry is tenant-scoped."

Chapter 13: Testing Multi-Tenant Systems#

13.1 Isolation Testing#

pythonTestTenantIsolation · 201 lines
# tests/test_tenant_isolation.py

"""
Tests to verify tenant isolation.

These tests are CRITICAL - they verify that tenants can't see each other's data.
Run these in CI/CD and before every deployment.
"""

import pytest
from uuid import uuid4


class TestTenantIsolation:
    """
    Test suite for verifying tenant isolation.
    """
    
    @pytest.fixture
    async def tenant_a(self, tenant_service):
        """Create test tenant A."""
        return await tenant_service.create_tenant(
            name="Test Tenant A",
            plan="standard"
        )
    
    @pytest.fixture
    async def tenant_b(self, tenant_service):
        """Create test tenant B."""
        return await tenant_service.create_tenant(
            name="Test Tenant B",
            plan="standard"
        )
    
    async def test_tenant_cannot_see_other_tenant_data(
        self,
        tenant_a,
        tenant_b,
        document_service
    ):
        """
        Verify that Tenant A cannot see Tenant B's documents.
        """
        # Create document as Tenant A
        with tenant_scope(tenant_a):
            doc_a = await document_service.create_document(
                CreateDocumentRequest(
                    title="Tenant A Secret",
                    content="This is confidential"
                ),
                created_by_user_id="user_a"
            )
        
        # Try to access from Tenant B
        with tenant_scope(tenant_b):
            # Should not find Tenant A's document
            doc = await document_service.get_document(doc_a.id)
            assert doc is None, "Tenant B should not see Tenant A's document!"
            
            # List should not include Tenant A's documents
            docs = await document_service.list_documents()
            doc_ids = [d.id for d in docs]
            assert doc_a.id not in doc_ids, "Tenant A's document leaked to Tenant B!"
    
    async def test_tenant_cannot_modify_other_tenant_data(
        self,
        tenant_a,
        tenant_b,
        document_repo
    ):
        """
        Verify that Tenant A cannot modify Tenant B's documents.
        """
        # Create document as Tenant A
        with tenant_scope(tenant_a):
            doc_a = await document_repo.create({
                "title": "Original Title",
                "content": "Original Content"
            })
        
        # Try to update from Tenant B
        with tenant_scope(tenant_b):
            result = await document_repo.update(
                doc_a.id,
                {"title": "Hacked Title"}
            )
            assert result is None, "Tenant B should not modify Tenant A's document!"
        
        # Verify document unchanged
        with tenant_scope(tenant_a):
            doc = await document_repo.find_by_id(doc_a.id)
            assert doc.title == "Original Title"
    
    async def test_tenant_cannot_delete_other_tenant_data(
        self,
        tenant_a,
        tenant_b,
        document_repo
    ):
        """
        Verify that Tenant A cannot delete Tenant B's documents.
        """
        # Create document as Tenant A
        with tenant_scope(tenant_a):
            doc_a = await document_repo.create({
                "title": "Important Document",
                "content": "Do not delete"
            })
        
        # Try to delete from Tenant B
        with tenant_scope(tenant_b):
            deleted = await document_repo.delete(doc_a.id)
            assert not deleted, "Tenant B should not delete Tenant A's document!"
        
        # Verify document still exists
        with tenant_scope(tenant_a):
            doc = await document_repo.find_by_id(doc_a.id)
            assert doc is not None
    
    async def test_rls_filters_unscoped_queries(
        self,
        tenant_a,
        tenant_b,
        tenant_pool
    ):
        """
        Test the RLS policy directly: a raw query with NO tenant filter
        must still return only the current tenant's rows.
        
        (RLS alone does not stop SQL injection: injected SQL could call
        set_config() to switch tenant. Always parameterise queries.)
        """
        with tenant_scope(tenant_a):
            async with await tenant_pool.connection() as conn:
                rows = await conn.fetch("SELECT tenant_id FROM documents")
        
        for row in rows:
            assert row["tenant_id"] == tenant_a.tenant_id
    
    async def test_cache_isolation(
        self,
        tenant_a,
        tenant_b,
        document_service,
        cache
    ):
        """
        Verify that cached data is tenant-isolated.
        """
        # Create and cache document as Tenant A
        with tenant_scope(tenant_a):
            doc_a = await document_service.create_document(
                CreateDocumentRequest(title="Cached Doc", content="Secret"),
                created_by_user_id="user_a"
            )
            # Access to populate cache
            await document_service.get_document(doc_a.id)
        
        # Verify cache key is tenant-scoped
        cache_key_a = f"tenant:{tenant_a.tenant_id}:doc:{doc_a.id}"
        assert await cache.exists(cache_key_a)
        
        # Tenant B should not hit this cache
        with tenant_scope(tenant_b):
            cache_key_b = f"tenant:{tenant_b.tenant_id}:doc:{doc_a.id}"
            assert not await cache.exists(cache_key_b)


class TestTenantContextPropagation:
    """
    Test that tenant context flows correctly through the system.
    """
    
    async def test_context_available_in_service(self, tenant_a, document_service):
        """Verify tenant context is available in service layer."""
        with tenant_scope(tenant_a):
            # Service should be able to get current tenant
            tenant = get_current_tenant()
            assert tenant.tenant_id == tenant_a.tenant_id
    
    async def test_context_propagates_to_repository(self, tenant_a, document_repo):
        """Verify tenant context propagates to repository."""
        with tenant_scope(tenant_a):
            # Repository should automatically use current tenant
            doc = await document_repo.create({"title": "Test", "content": "Test"})
            assert doc.tenant_id == tenant_a.tenant_id
    
    async def test_no_context_raises_error(self, document_service):
        """Verify operations fail without tenant context."""
        # Outside tenant scope
        with pytest.raises(TenantContextError):
            await document_service.list_documents()
    
    async def test_context_cleared_after_scope(self, tenant_a):
        """Verify context is cleared after scope exits."""
        with tenant_scope(tenant_a):
            assert get_current_tenant().tenant_id == tenant_a.tenant_id
        
        # After scope, context should be cleared
        with pytest.raises(TenantContextError):
            get_current_tenant()

13.2 Load Testing Multi-Tenant Systems#

pythonTenantLoadTest · 76 lines
# tests/load_test_tenants.py

"""
Load testing for multi-tenant systems.

Verify that:
1. Multiple tenants can operate concurrently
2. One tenant's load doesn't affect others
3. System handles tenant context correctly under load
"""

import asyncio
import httpx
from locust import HttpUser, task, between


class TenantLoadTest(HttpUser):
    """
    Locust load test simulating multiple tenants.
    """
    wait_time = between(1, 3)
    
    def on_start(self):
        """Setup - assign this user to a random tenant."""
        import random
        self.tenant_id = f"tenant_{random.randint(1, 100)}"
        # The tenant comes from the token; the header is only a hint
        self.headers = {
            "Authorization": f"Bearer {test_token_for(self.tenant_id)}",
            "X-Tenant-ID": self.tenant_id,
        }
    
    @task(3)
    def list_documents(self):
        """List documents for this tenant."""
        self.client.get("/api/documents", headers=self.headers)
    
    @task(2)
    def create_document(self):
        """Create a document for this tenant."""
        self.client.post(
            "/api/documents",
            json={"title": "Load Test Doc", "content": "Test content"},
            headers=self.headers
        )
    
    @task(1)
    def get_document(self):
        """Get a specific document."""
        self.client.get(f"/api/documents/doc_1", headers=self.headers)


async def verify_isolation_under_load():
    """
    Verify tenant isolation while system is under load.
    
    Run this while load test is active.
    """
    tenants = ["tenant_1", "tenant_2", "tenant_3"]
    
    async def check_tenant(tenant_id):
        """Verify tenant only sees their own data."""
        async with httpx.AsyncClient() as client:
            response = await client.get(
                "http://localhost:8000/api/documents",
                headers={"Authorization": f"Bearer {test_token_for(tenant_id)}"}
            )
            
            docs = response.json()["documents"]
            for doc in docs:
                assert doc["tenant_id"] == tenant_id, \
                    f"Tenant {tenant_id} saw document from {doc['tenant_id']}!"
    
    # Check all tenants concurrently
    await asyncio.gather(*[check_tenant(t) for t in tenants])
    print("Isolation verified under load!")
Quick check2 questions · +5 XP each

Testing tenant isolation

  1. Q1/2 In the isolation tests, Tenant B tries to update Tenant A's document. Why does the repository return None instead of raising a permission error?

    Answer & explanation

    Answer: A

    WHERE id = ... AND tenant_id = ... simply finds no row for Tenant B, so nothing changes and nothing is returned. To Tenant B the document looks like it does not exist, which also avoids confirming that the ID belongs to someone else.

  2. Q2/2 Why run the cross-tenant isolation checks while a load test is running, not only in unit tests?

    Answer & explanation

    Answer: C

    Leaks like a pooled connection keeping the previous tenant, or context bleeding between requests, need many tenants running at once to appear. Unit tests can create several tenants (the fixtures do), but they run one scope at a time.

Summary#

Day 1 key takeaways
Isolation spectrum
  • Shared tables: Lowest cost, application-enforced
  • Row-level security: Database-enforced, same tables
  • Schema per tenant: Stronger isolation, same server
  • Database per tenant: Physical isolation, higher cost
  • Dedicated infrastructure: Complete isolation, highest cost
Tenant context
  • Extract from auth, not client headers
  • Propagate through all layers
  • Include in async jobs and messages
  • Tag all logs and metrics
Row-level security
  • PostgreSQL RLS is powerful
  • Defense in depth with application filtering
  • Set context per connection
  • Test extensively
Hybrid approach
  • Different isolation for different tiers
  • Self-serve: Shared infrastructure
  • Enterprise: Dedicated resources
  • Migration path between tiers
Default choice
  • Start with shared tables + RLS
  • Add schema separation for compliance
  • Offer dedicated for enterprise deals
  • Never compromise on context propagation

Further Reading#

Documentation:

Engineering Blogs:

  • Blog

    Slack Engineering: "Scaling Datastores at Slack with Vitess"

  • Read

    Salesforce Architecture: "The Metadata-Driven Architecture"

Books:

  • Read

    "Building Multi-Tenant SaaS Architectures" (AWS)

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 01 · Tenant Isolation Strategies
Finish Day 1Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~9 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

AmazonDocs · ~5 min

Silo, Pool, and Bridge Models

What
AWS's reference definitions of the three SaaS isolation models.
Key idea
Silo gives each tenant dedicated resources, pool shares everything with tenant-scoped access, and bridge mixes the two per layer of the stack.
Why here
The isolation patterns of this lesson under the names most SaaS architects use: database-per-tenant, shared schema and hybrids.
taught here: Isolation patterns
AmazonBlog post · ~11 min

Multi-tenant data isolation with PostgreSQL Row Level Security

Michael Beardsley · 2020
What
A step-by-step guide to isolating tenants in one shared Postgres schema.
Key idea
RLS policies compare each row's tenant_id with a session setting, so the database itself filters every query even if application code forgets to.
Why here
The row-level security implementation of this lesson, with the session-context plumbing it needs.
taught here: Row-level security

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…