Fight it like the real interview: each phase runs against its time budget, you write your answer in a scratchpad first, then reveal the model answer side by side and grade yourself.
The Interview Begins#
You walk into the interview room at a well-known B2B SaaS company. The interviewer, a Staff Engineer, greets you warmly.
"Thanks for coming in today. We're going to work through a system design problem that reflects challenges we face here. I want to see how you think through complex multi-tenant systems. Feel free to ask questions — this is collaborative."
They turn to the whiteboard and write:
You're building a B2B SaaS platform where companies can:
- Store and organize documents (contracts, policies, reports)
- Search across all their documents with full-text search
- Collaborate on documents with comments and version history
- Set granular permissions (who can view/edit/delete)
- Generate audit trails for compliance
- Export their data for compliance or migration
Customer profile
- 500 enterprise customers (tenants)
- Mix of US, EU, and APAC customers
- Some customers in regulated industries (healthcare, finance)
- Largest customer has 50,000 users and 10M documents
- Smallest customers have 50 users and 10K documents
Key concerns from sales
- EU customers asking about GDPR and data residency
- Healthcare customers need HIPAA compliance
- Enterprise customers want SSO and audit logs
- Several deals lost because "search was too slow"
"Take a few minutes to think about this, then walk me through your approach. We have about 50 minutes."
500 tenants across the US, EU and APAC (the largest with 50,000 users and 10 million documents) need search under 500 ms p99 and EU data kept in the EU: 100 million documents of 500 KB are 50 TB, 180 TB raw with metadata and three copies, and 1,000 requests a second at peak are 60% search over a ~3 TB index. A global control plane holds only tenant registry, flags, quotas and routing above three independent regional data planes, uploads go straight to the regional bucket via presigned URLs, and the search service adds tenant and permission filters. The deep dives add five isolation layers, quotas by plan, regional routing, verified deletion and defence in depth; at 5,000 tenants PostgreSQL is sharded by tenant_id, and any cross-tenant access pages immediately.
Phase 1: Requirements Clarification (5 minutes)#
You take a breath and start asking questions.
"Before I dive into the design, I'd like to clarify some requirements. Let me start with scale and then move to compliance."
Clarifying Questions#
"For scale — you mentioned 500 tenants with the largest having 10M documents. What's the average document size, and what's our total storage footprint?"
"Average document is about 500KB. Mix of PDFs, Word docs, and some larger files like presentations. Total storage across all tenants is about 50TB, growing 20% annually."
"What's our request volume? How many searches, uploads, downloads per day?"
"Peak hours see about 1,000 requests per second across all tenants. Searches are the most common operation — maybe 60% of traffic. Uploads are bursty, especially end of quarter when companies finalize contracts."
"For the EU data residency requirement — do EU customers need all their data in the EU, or just personal data?"
"Great question. Our legal team says all document content and metadata for EU customers must stay in EU. Some operational data like aggregate metrics can be global."
"For the regulated industries — are we targeting HIPAA certification, or just 'HIPAA-ready' architecture?"
"We want the architecture to support HIPAA. Full certification is a business decision, but the technical foundation must be there. Same for SOC 2."
"One more — for the largest customers, do they get dedicated infrastructure, or is everyone on shared infrastructure with isolation?"
"We want to offer both. Most customers on shared infrastructure with strong isolation. Enterprise tier customers can opt for dedicated resources at premium pricing."
"Perfect. Let me summarize the requirements."
Functional Requirements#
1. Document management
- Upload documents (PDF, Word, Excel, images)
- Organize in folders/hierarchies
- Version history with rollback
- Preview and download
- Bulk operations (move, delete, export)
2. Search
- Full-text search across document content
- Metadata search (author, date, tags)
- Filters and facets
- Search within folders
- < 500ms p99 latency for search
3. Collaboration
- Comments on documents
- @mentions and notifications
- Sharing with granular permissions
- Activity feed per document
- Real-time presence (who's viewing)
4. Access control
- Role-based permissions (viewer, editor, admin)
- Folder-level and document-level permissions
- External sharing with expiring links
- SSO integration (SAML, OIDC)
- MFA support
5. Compliance
- Complete audit trail (who did what, when)
- Data export (GDPR portability)
- Data deletion (GDPR right to erasure)
- Retention policies
- Legal hold capability
Non-Functional Requirements#
1. Scale
- 500 tenants, growing to 2,000
- 50TB storage, growing 20% annually
- 1,000 requests/second peak
- Largest tenant: 50K users, 10M documents
- Support 100K concurrent users globally
2. Latency
- Search: < 500ms p99
- Document preview: < 2s p99
- Upload (10MB): < 5s p99
- API calls: < 200ms p99
3. Availability
- 99.9% uptime SLA
- No single point of failure
- Graceful degradation
- < 4 hours RTO, < 1 hour RPO
4. Compliance
- EU data residency for EU customers
- HIPAA-ready architecture
- SOC 2 Type II controls
- GDPR compliance (consent, deletion, portability)
5. Security
- Encryption at rest and in transit
- Tenant isolation (data and resources)
- Zero trust architecture
- Regular security audits
Before the numbers: how much storage?
The interviewer gave you ~100 million documents averaging 500 KB, across 500 tenants. Add 20% for metadata and keep 3 copies. How much raw storage is that, and how big is the average tenant?
One strong answer
- Documents: 100 million × 500 KB = 50 TB; with metadata, ×1.2 = 60 TB; with 3 copies, 180 TB raw.
- Average tenant: 100 million ÷ 500 = 200,000 documents, about 100 GB. The largest tenant (10 million documents) is 5 TB, fifty times the average.
- So one tenant can hold a tenth of all the data, which is why noisy neighbours and dedicated tiers come up later.
Phase 2: Back of the Envelope Estimation (5 minutes)#
"Let me work through the numbers to validate our architecture decisions."
Storage Estimation#
Current state
- Total documents: ~100M (across all tenants)
- Average document size: 500KB
- Total storage: 100M × 500KB = 50TB
- With metadata overhead: 50TB × 1.2 = 60TB
- With 3x replication: 60TB × 3 = 180TB raw storage
Growth projection (20% annually)
| Year 1 | Year 2 | Year 3 | Year 5 |
|---|---|---|---|
| 60TB | 72TB | 86TB | ~124TB |
Per-tenant storage
- Average tenant: 100GB (200K documents)
- Largest tenant: 5TB (10M documents)
- Smallest tenant: 5GB (10K documents)
Traffic Estimation#
- Peak requests: 1,000/second
- Daily requests: ~30M (1,000/s × 8 peak hours ≈ 28.8M, plus light off-peak traffic)
Breakdown by operation
| Operation | Rate | Share |
|---|---|---|
| Search | 600/sec | 60% |
| Read/download | 250/sec | 25% |
| Write/upload | 100/sec | 10% |
| Other (permissions, etc) | 50/sec | 5% |
Search index size
- Average document text: 10KB extracted text
- Total index size: 100M × 10KB = 1TB
- With Elasticsearch overhead: ~3TB
Infrastructure Estimation#
Compute
- API servers: 20 instances (m5.xlarge)
- Search cluster: 9 nodes (r5.2xlarge) - 3 per region
- Background workers: 10 instances (m5.large)
- Total: ~40 instances
Storage
- PostgreSQL: Multi-AZ, 2TB per region
- Elasticsearch: ~3TB in total, split across regions by tenant
- S3: 180TB raw if you count 3 copies (S3 bills ~60TB; its replication is built in)
- Redis: 50GB cluster
Regions
- US (us-east-1): Primary for US customers
- EU (eu-central-1): Primary for EU customers
- APAC (ap-southeast-1): Primary for APAC customers
Requirements and estimation
Q1/2 Why does the candidate ask whether EU customers need all their data in the EU or just personal data?
Answer & explanation
Answer: B
The answer draws the line for the whole architecture: document content and metadata must stay in the EU, while aggregate operational metrics can be global. That is what later justifies regional data planes plus a global control plane holding no tenant data.
Q2/2 Extracted text averages 10KB per document and Elasticsearch adds roughly 3x overhead. If the corpus grows from 100M to 200M documents, about how big is the search index?
Answer & explanation
Answer: C
200M × 10KB = 2TB of raw text, and with ~3x Elasticsearch overhead that is ~6TB. 2TB forgets the overhead, and 100TB is the size of the 500KB original files, not the text index.
Phase 3: High-Level Design (10 minutes)#
"Now let me sketch out the high-level architecture. Given our multi-region, multi-tenant requirements, I'll design for regional data isolation with a global control plane."
System Architecture#
%% title: High-level architecture
flowchart TD
GCP["Global Control Plane<br/>Tenant Config · Routing Rules<br/>Feature Flags"]:::core
GCP --> US_CDN
GCP --> EU_CDN
GCP --> AP_CDN
subgraph APR["APAC Region · APAC DATA ONLY"]
AP_CDN["CDN"]:::edge --> AP_WAF["WAF"]:::edge --> AP_ALB["ALB"]:::edge --> AP_API["API Cluster"]:::service
AP_API --> AP_PG@{ icon: "tech:postgresql", label: "PG" }
AP_API --> AP_ES@{ icon: "tech:elasticsearch", label: "ES" }
AP_API --> AP_S3@{ icon: "tech:s3", label: "S3 Bucket" }
end
subgraph EUR["EU Region · EU DATA ONLY"]
EU_CDN["CDN"]:::edge --> EU_WAF["WAF"]:::edge --> EU_ALB["ALB"]:::edge --> EU_API["API Cluster"]:::service
EU_API --> EU_PG@{ icon: "tech:postgresql", label: "PG" }
EU_API --> EU_ES@{ icon: "tech:elasticsearch", label: "ES" }
EU_API --> EU_S3@{ icon: "tech:s3", label: "S3 Bucket" }
end
subgraph USR["US Region · US DATA ONLY"]
US_CDN["CDN"]:::edge --> US_WAF["WAF"]:::edge --> US_ALB["ALB"]:::edge --> US_API["API Cluster"]:::service
US_API --> US_PG@{ icon: "tech:postgresql", label: "PG" }
US_API --> US_ES@{ icon: "tech:elasticsearch", label: "ES" }
US_API --> US_S3@{ icon: "tech:s3", label: "S3 Bucket" }
end
class AP_PG,AP_ES,AP_S3,EU_PG,EU_ES,EU_S3,US_PG,US_ES,US_S3 storeNO CROSS-REGION DATA FLOW FOR TENANT DATA.
Component Breakdown#
"Let me walk through each major component and the key design decisions."
1. Global Control Plane#
Purpose: Manage tenant configuration and routing without storing personal data.
Contains
- Tenant registry (which region each tenant is in)
- Feature flags (which features enabled per tenant)
- Plan/quota configuration
- Routing rules
- Global admin interface
Does NOT contain
- User data
- Document content
- Any personal information
- Audit logs (those stay regional)
Technology: Single PostgreSQL with read replicas in each region for low-latency lookups.
2. Regional Data Plane#
Purpose: Store and process all tenant data within the region.
API cluster— stateless, horizontally scaledPostgreSQL— users, documents metadata, permissionsElasticsearch— search indexS3— document storageRedis— cache, sessions, rate limitingKafka— event streaming, audit logs
Key Decision: Complete data isolation. EU tenant data never leaves EU region.
3. Document Processing Pipeline#
%% title: Document upload flow
flowchart TD
U@{ icon: "sd:user", label: "User uploads document" } --> GW["API Gateway<br/>validates, rate limits"]:::edge
GW --> UP["Upload Service<br/>generates presigned S3 URL"]:::core
UP --> S3@{ icon: "tech:s3", label: "S3 (encrypted)<br/>Store document" }
UP --> PG@{ icon: "tech:postgresql", label: "PostgreSQL<br/>Store metadata" }
S3 --> Q@{ icon: "sd:queue", label: "Processing Queue<br/>async processing" }
Q --> TE["Text Extract"]:::service
Q --> TH["Thumbnail Generate"]:::service
TE --> ES@{ icon: "tech:elasticsearch", label: "Elasticsearch<br/>Update index<br/>index for search" }
class U client
class S3,PG,ES store
class Q queue4. Search Architecture#
%% title: Search flow
flowchart TD
U@{ icon: "sd:user", label: "User searches #quot;contract 2024#quot;" } --> GW["API Gateway"]:::edge
GW --> SS["Search Service<br/>Add tenant_id filter (CRITICAL!)<br/>Add permission filter<br/>Build ES query"]:::core
SS --> ES@{ icon: "tech:elasticsearch", label: "Elasticsearch<br/>Tenant-specific index OR<br/>Filtered query with tenant_id" }
ES --> R(["Results with<br/>permission check"]):::ok
class U client
class ES storeHigh-level design
Q1/3 A teammate proposes storing user emails in the global control plane so login can find a user's region in one lookup. What is the problem?
Answer & explanation
Answer: A
The control plane is deliberately limited to tenant registry, flags, quotas and routing, so it can be global; adding user emails breaks "no cross-region data flow for tenant data". Load and latency are already handled by read replicas in each region.
Q2/3 Why does the upload service hand the client a presigned S3 URL instead of receiving the file bytes itself?
Answer & explanation
Answer: B
The API only authorizes and issues a scoped URL; the bytes go directly to S3 and processing happens asynchronously off the queue. The server, not the client, chooses the bucket, otherwise residency could be bypassed, and metadata still goes to PostgreSQL.
Q3/3 In the search flow, where is the
tenant_idfilter added?Answer & explanation
Answer: D
The Search Service adds the tenant filter (marked CRITICAL) and a permission filter on the server; anything the client sends can be forged. Even with a tenant-specific index, it is the service that decides which index to query.
What stops one bug from leaking a tenant?
A developer ships a search endpoint that forgets the tenant filter. With the architecture so far, what should still stop Tenant A from seeing Tenant B's contracts? Name the layers you'd want, from the request to the files.
One strong answer
- The request: the tenant comes from the verified JWT, never from the body or a header the client controls.
- The application: a repository that adds
tenant_idto every query, and a search service that adds the tenant filter server-side. - The database: row-level security, so even raw SQL returns only the current tenant's rows.
- Search and storage: per-tenant indexes for enterprise, tenant prefixes in S3 paths (the server only signs URLs for keys under the tenant's prefix), and per-tenant keys. Any one layer can fail; the others still hold.
Phase 4: Deep Dives (20 minutes)#
"Great high-level design. Let's dive deeper into some areas. First, tell me about tenant isolation. How do you ensure one tenant can never see another's data?"
Deep Dive 1: Tenant Isolation (Week 9, Day 1)#
"Tenant isolation is the most critical aspect of this system. I'll implement defense in depth with multiple isolation layers."
The Problem#
%% title: Without proper isolation flowchart TD A(["Tenant A searches for #quot;confidential#quot;"]):::client --> Q["SELECT * FROM documents<br/>WHERE content LIKE '%confidential%'"]:::service Q --> R["Returns documents from ALL tenants!<br/>Including Tenant B's confidential contracts."]:::bad
This is a catastrophic data breach.
Multi-Layer Isolation Strategy#
- LAYER 1: REQUEST ROUTING
- Tenant taken from the verified JWT claims
- Subdomain (
acme.docplatform.com) must match the JWT's tenant - Set in request context at API gateway
- CANNOT be overridden by request parameters or headers
- LAYER 2: APPLICATION ENFORCEMENT
- TenantContext set for every request
- Repository layer auto-filters by
tenant_id - Service layer validates tenant ownership
- Audit log records tenant context
- LAYER 3: DATABASE ENFORCEMENT (RLS)
- Row Level Security policies on all tables
- Database session has
current_tenantset - Even raw SQL queries are filtered
- App role can't bypass it (superusers and BYPASSRLS roles still can, so keep those out of the app path)
- LAYER 4: SEARCH INDEX ISOLATION
- Option A: Index per tenant (strong isolation)
- Option B: Shared index with
tenant_idfilter - We use Option A for enterprise, B for standard
- Search service enforces tenant filter
- LAYER 5: STORAGE ISOLATION
- S3 path includes
tenant_id - IAM policies restrict cross-tenant access
- Presigned URLs issued only for keys under the tenant's prefix (each URL covers one object key)
- Encryption keys per tenant (enterprise)
- S3 path includes
A search from acme.docplatform.com takes its tenant from the verified JWT, and the subdomain must match it. The tenant's config becomes an immutable TenantContext (plan, isolation level, region) and the plan's quota is checked (Standard: 100 search calls a minute). The repository adds tenant_id = acme and SET LOCAL app.current_tenant_id makes PostgreSQL row-level security filter every query; search filters the shared index by tenant_id (Enterprise tenants get their own). A token for another tenant gets 403 Tenant mismatch, the 101st search in a minute is rejected until the window resets, and a query that forgets its tenant filter still returns only acme's rows.
Implementation#
# tenant_isolation.py - Core tenant context management
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Optional
from enum import Enum
# Thread-safe tenant context
_tenant_context: ContextVar[Optional['TenantContext']] = ContextVar(
'tenant_context', default=None
)
class IsolationLevel(Enum):
"""Tenant isolation levels by plan."""
SHARED = "shared" # Shared tables, RLS
SCHEMA = "schema" # Schema per tenant
DATABASE = "database" # Database per tenant
DEDICATED = "dedicated" # Dedicated infrastructure
@dataclass(frozen=True)
class TenantContext:
"""Immutable tenant context for request processing."""
tenant_id: str
tenant_name: str
region: str
plan: str
isolation_level: IsolationLevel
encryption_key_id: str
features: frozenset
def get_current_tenant() -> TenantContext:
"""Get current tenant context. Raises if not set."""
ctx = _tenant_context.get()
if ctx is None:
raise TenantContextError("No tenant context set")
return ctx
class TenantMiddleware:
"""
Middleware that establishes tenant context for every request.
Tenant is determined from:
1. JWT token claims (signature-verified) or API key lookup
2. Subdomain (acme.docplatform.com → acme), which must MATCH (1)
NEVER from request body, query parameters or the Host header alone
(the client controls all of those).
"""
async def __call__(self, request, call_next):
# Authoritative: tenant from the verified token
token = request.headers.get("authorization", "").replace("Bearer ", "")
claims = decode_jwt(token) # Verifies signature and expiry
tenant_id = claims.get("tenant_id")
if not tenant_id:
return JSONResponse({"error": "Tenant not identified"}, status_code=400)
# The subdomain is only a hint and must agree with the token
host = request.headers.get("host", "")
subdomain = host.split(".")[0]
if subdomain not in ("www", "api") and subdomain != tenant_id:
return JSONResponse({"error": "Tenant mismatch"}, status_code=403)
# Load tenant configuration
tenant_config = await self.tenant_service.get_tenant(tenant_id)
if not tenant_config:
return JSONResponse({"error": "Tenant not found"}, status_code=404)
# Set immutable context
context = TenantContext(
tenant_id=tenant_config.id,
tenant_name=tenant_config.name,
region=tenant_config.region,
plan=tenant_config.plan,
isolation_level=IsolationLevel(tenant_config.isolation_level),
encryption_key_id=tenant_config.encryption_key_id,
features=frozenset(tenant_config.features)
)
# Set context for this request
token = _tenant_context.set(context)
try:
response = await call_next(request)
return response
finally:
_tenant_context.reset(token)
class TenantAwareRepository:
"""
Repository base class that enforces tenant isolation.
ALL database access goes through this class.
"""
def __init__(self, db_pool):
self.db = db_pool
async def find_by_id(self, table: str, id: str):
"""Find record by ID within current tenant."""
tenant = get_current_tenant()
# ALWAYS filter by tenant_id
return await self.db.fetchrow(
f"SELECT * FROM {table} WHERE id = $1 AND tenant_id = $2",
id, tenant.tenant_id
)
async def find_all(self, table: str, filters: dict = None):
"""Find all records within current tenant."""
tenant = get_current_tenant()
query = f"SELECT * FROM {table} WHERE tenant_id = $1"
params = [tenant.tenant_id]
if filters:
for i, (key, value) in enumerate(filters.items(), start=2):
query += f" AND {key} = ${i}"
params.append(value)
return await self.db.fetch(query, *params)
async def create(self, table: str, data: dict):
"""Create record with automatic tenant_id."""
tenant = get_current_tenant()
# Force tenant_id - cannot be overridden
data["tenant_id"] = tenant.tenant_id
columns = ", ".join(data.keys())
placeholders = ", ".join(f"${i+1}" for i in range(len(data)))
return await self.db.fetchrow(
f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *",
*data.values()
)Database Row-Level Security#
-- PostgreSQL Row-Level Security Setup
-- Enable RLS on all tenant tables
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE folders ENABLE ROW LEVEL SECURITY;
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- Table owners bypass RLS unless it is forced
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
-- ...same for every tenant table
-- Create policy that filters by current tenant
CREATE POLICY tenant_isolation_policy ON documents
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation_policy ON users
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Similar for all tables...
-- Application sets tenant at the start of each transaction.
-- SET LOCAL resets at COMMIT/ROLLBACK, so a pooled connection
-- can't carry one tenant's setting into the next request:
-- BEGIN; SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
-- (Day 1 shows the other safe option: a session-level setting
-- that the connection wrapper clears before release. SET LOCAL
-- needs no cleanup step, which is why this design uses it.)
-- Now even this query is filtered:
-- SELECT * FROM documents;
-- Only returns current tenant's documents!"What about the large enterprise tenant with 10M documents? Don't they need stronger isolation?"
"Absolutely. For enterprise customers, we offer dedicated isolation."
Enterprise Tier Isolation#
# enterprise_isolation.py
class EnterpriseTenantManager:
"""
Manages dedicated resources for enterprise tenants.
"""
async def provision_enterprise_tenant(
self,
tenant_id: str,
config: EnterpriseConfig
):
"""
Provision dedicated resources for enterprise tenant.
Options:
- Dedicated database
- Dedicated Elasticsearch index
- Dedicated encryption keys
- Dedicated compute (optional)
"""
# 1. Create dedicated database
if config.dedicated_database:
await self._create_tenant_database(tenant_id)
# 2. Create dedicated search index
await self._create_tenant_search_index(tenant_id)
# 3. Create tenant-specific KMS key
key_id = await self._create_tenant_kms_key(tenant_id)
# 4. Update tenant config
await self.tenant_service.update_tenant(
tenant_id,
isolation_level=IsolationLevel.DATABASE,
encryption_key_id=key_id,
database_name=f"tenant_{tenant_id}",
search_index=f"documents_{tenant_id}"
)
async def _create_tenant_database(self, tenant_id: str):
"""Create dedicated PostgreSQL database for tenant."""
safe_name = tenant_id.replace("-", "_")
await self.admin_db.execute(f"""
CREATE DATABASE tenant_{safe_name};
""")
# Run migrations on new database
await self._run_migrations(f"tenant_{safe_name}")
async def _create_tenant_kms_key(self, tenant_id: str) -> str:
"""Create dedicated KMS key for tenant encryption."""
response = await self.kms.create_key(
Description=f"Encryption key for tenant {tenant_id}",
KeyUsage="ENCRYPT_DECRYPT",
Tags=[
{"TagKey": "tenant_id", "TagValue": tenant_id},
{"TagKey": "purpose", "TagValue": "document_encryption"}
]
)
return response["KeyMetadata"]["KeyId"]Deep Dive 2: Noisy Neighbor Prevention (Week 9, Day 2)#
"You mentioned the largest tenant has 10M documents. How do you prevent them from impacting smaller tenants?"
"This is the noisy neighbor problem. I'd implement quotas and fair scheduling at multiple levels."
Resource Quota System#
| Resource | Standard | Professional | Enterprise |
|---|---|---|---|
| API calls/minute | 1,000 | 10,000 | 100,000 |
| Search calls/minute | 100 | 1,000 | 10,000 |
| Storage (GB) | 100 | 1,000 | 10,000 |
| Documents | 100,000 | 1,000,000 | Unlimited* |
| Users | 100 | 1,000 | Unlimited |
| Concurrent uploads | 5 | 20 | 100 |
| Max file size (MB) | 50 | 200 | 500 |
| Search query timeout | 10s | 30s | 60s |
| Export rate (docs/hr) | 10,000 | 100,000 | 1,000,000 |
* "Unlimited" = 100M with fair use policy
These plans are this document platform's own, sized for documents, search and exports, so they differ from Day 2's example tiers for a generic SaaS API.
Implementation#
# noisy_neighbor/rate_limiter.py
class TenantResourceManager:
"""
Manages and enforces resource quotas per tenant.
"""
def __init__(self, redis, quota_config):
self.redis = redis
self.quotas = quota_config
async def check_rate_limit(
self,
tenant_id: str,
resource: str,
cost: int = 1
) -> RateLimitResult:
"""
Check if request is within rate limits.
Uses a fixed-window counter per tenant (simple, but allows up to
2x the limit across a window boundary; a token bucket or sliding
window smooths that).
"""
tenant = await self.get_tenant_config(tenant_id)
limit = self.quotas[tenant.plan][resource]
key = f"ratelimit:{tenant_id}:{resource}"
# Lua script for atomic check-and-decrement
script = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local cost = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- Get current count
local current = tonumber(redis.call('GET', key) or '0')
if current + cost <= limit then
redis.call('INCRBY', key, cost)
if current == 0 then
-- Start the window once; re-setting EXPIRE on every hit
-- would keep the counter alive forever under steady load
redis.call('EXPIRE', key, window)
end
return {1, limit - current - cost}
else
local ttl = redis.call('TTL', key)
return {0, ttl}
end
"""
# redis-py: eval(script, numkeys, *keys_and_args)
result = await self.redis.eval(
script, 1, key, limit, cost, 60, time.time()
)
allowed = result[0] == 1
if not allowed:
await self._record_throttle(tenant_id, resource)
return RateLimitResult(
allowed=allowed,
remaining=result[1] if allowed else 0,
retry_after=result[1] if not allowed else None
)
async def check_concurrent_limit(
self,
tenant_id: str,
operation: str
) -> bool:
"""
Check concurrent operation limit.
Prevents one tenant from using all worker capacity.
"""
tenant = await self.get_tenant_config(tenant_id)
limit = self.quotas[tenant.plan][f"concurrent_{operation}"]
key = f"concurrent:{tenant_id}:{operation}"
current = await self.redis.scard(key) # A set: GET would raise WRONGTYPE
return current < limit
async def acquire_concurrent_slot(
self,
tenant_id: str,
operation: str,
operation_id: str,
ttl: int = 300
) -> bool:
"""Acquire a concurrent operation slot (atomic check-and-add)."""
tenant = await self.get_tenant_config(tenant_id)
limit = self.quotas[tenant.plan][f"concurrent_{operation}"]
key = f"concurrent:{tenant_id}:{operation}"
# Check and add in one Lua script, or two workers can both see
# "limit - 1" and both take the last slot
script = """
if redis.call('SCARD', KEYS[1]) < tonumber(ARGV[1]) then
redis.call('SADD', KEYS[1], ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
end
return 0
"""
result = await self.redis.eval(
script, 1, key, limit, operation_id, ttl
)
return result == 1
async def release_concurrent_slot(
self,
tenant_id: str,
operation: str,
operation_id: str
):
"""Release a concurrent operation slot."""
key = f"concurrent:{tenant_id}:{operation}"
await self.redis.srem(key, operation_id)
class SearchQueryGuard:
"""
Guards search queries against noisy neighbors.
"""
async def guard_search(
self,
tenant_id: str,
query: SearchQuery
) -> tuple[bool, Optional[int]]:
"""
Check if search query should be allowed.
Returns (allowed, timeout_seconds).
"""
tenant = await self.tenant_service.get_tenant(tenant_id)
# Get tenant's search timeout
timeout = self.quotas[tenant.plan]["search_timeout"]
# Estimate query cost
cost = self._estimate_query_cost(query)
if cost > 100: # High cost query
# Check if tenant can run expensive queries
if tenant.plan == "standard":
return False, None
# Use longer timeout for expensive queries
timeout = min(timeout * 2, 120)
return True, timeout
def _estimate_query_cost(self, query: SearchQuery) -> int:
"""Estimate query cost based on complexity."""
cost = 1
# Wildcard queries are expensive
if "*" in query.text or "?" in query.text:
cost += 10
# Regex queries are very expensive
if query.use_regex:
cost += 50
# Large result sets
if query.limit > 100:
cost += query.limit // 100
return costFair Scheduling for Background Jobs#
# noisy_neighbor/fair_scheduler.py
class TenantFairQueue:
"""
Fair queue that prevents one tenant from monopolizing workers.
Uses weighted fair queuing based on tenant plan.
"""
PLAN_WEIGHTS = {
"standard": 1,
"professional": 3,
"enterprise": 10
}
async def enqueue(
self,
tenant_id: str,
job: Job
):
"""Add job to tenant's queue."""
tenant = await self.tenant_service.get_tenant(tenant_id)
# Check queue depth limit
current_depth = await self.get_queue_depth(tenant_id)
max_depth = self.quotas[tenant.plan]["max_queue_depth"]
if current_depth >= max_depth:
raise QueueFullError(
f"Queue full for tenant {tenant_id}. "
f"Current: {current_depth}, Max: {max_depth}"
)
# Add to tenant's queue
await self.redis.lpush(
f"queue:{tenant_id}",
job.serialize()
)
async def dequeue(self) -> Optional[tuple[str, Job]]:
"""
Dequeue next job using weighted fair scheduling.
Higher-weight tenants get proportionally more slots.
"""
# Get all tenant queues with pending jobs
tenant_queues = await self._get_active_queues()
if not tenant_queues:
return None
# Calculate weighted selection
weighted_tenants = []
for tenant_id, depth in tenant_queues.items():
tenant = await self.tenant_service.get_tenant(tenant_id)
weight = self.PLAN_WEIGHTS.get(tenant.plan, 1)
# Weight decreases as queue depth increases (fairness)
adjusted_weight = weight / (1 + depth * 0.1)
weighted_tenants.append((tenant_id, adjusted_weight))
# Select tenant based on weights
selected_tenant = self._weighted_random_choice(weighted_tenants)
# Pop job from selected tenant's queue
job_data = await self.redis.rpop(f"queue:{selected_tenant}")
if job_data:
return selected_tenant, Job.deserialize(job_data)
return NoneTenant isolation and noisy neighbors
Q1/3 A new endpoint reads
tenant_idfrom the JSON body so admins can "switch tenants". Which isolation rule does this break?Answer & explanation
Answer: B
TenantMiddlewarebuilds an immutable context from the verified JWT or API key; the subdomain is only a hint that must match it. It never reads the tenant from the body or query. A body field is attacker-controlled, so the repository would faithfully filter by whatever tenant the attacker names.Q2/3 A developer bypasses
TenantAwareRepositoryand runsSELECT * FROM documentson a connection whereapp.current_tenant_idis set. What happens?Answer & explanation
Answer: A
The RLS policy
tenant_id = current_setting('app.current_tenant_id')applies to every query on the table, raw SQL included, which is why it is the database-level backup behind the repository. It filters rows rather than raising an error.Q3/3 A huge tenant enqueues 50,000 export jobs at quarter end. How does
TenantFairQueuestop a small tenant's single job from waiting behind all of them?Answer & explanation
Answer: D
Each tenant has its own queue,
enqueuerejects jobs beyond the plan's max depth, anddequeuedivides the plan weight by(1 + depth * 0.1), so a deep queue's share falls. A single global FIFO is exactly what would trap the small job.
Deep Dive 3: Data Residency and GDPR (Week 9, Day 3)#
"You mentioned EU customers need data in EU. How exactly does that work, and what about GDPR compliance?"
"Data residency and GDPR are handled through regional isolation and comprehensive data management. Let me walk through both."
Regional Data Routing#
# data_residency/router.py
class RegionalDataRouter:
"""
Routes data operations to the correct region based on tenant.
EU tenant data NEVER touches US infrastructure.
"""
REGION_CONFIGS = {
"us": {
"database": "postgres://db-us.internal:5432/app",
"elasticsearch": "https://es-us.internal:9200",
"s3_bucket": "documents-us-east-1",
"redis": "redis://cache-us.internal:6379"
},
"eu": {
"database": "postgres://db-eu.internal:5432/app",
"elasticsearch": "https://es-eu.internal:9200",
"s3_bucket": "documents-eu-central-1",
"redis": "redis://cache-eu.internal:6379"
},
"apac": {
"database": "postgres://db-apac.internal:5432/app",
"elasticsearch": "https://es-apac.internal:9200",
"s3_bucket": "documents-ap-southeast-1",
"redis": "redis://cache-apac.internal:6379"
}
}
async def get_database_connection(self, tenant_id: str):
"""Get database connection for tenant's region."""
tenant = await self.tenant_service.get_tenant(tenant_id)
config = self.REGION_CONFIGS[tenant.region]
return await self.connection_pools[tenant.region].acquire()
async def get_storage_bucket(self, tenant_id: str) -> str:
"""Get S3 bucket for tenant's region."""
tenant = await self.tenant_service.get_tenant(tenant_id)
return self.REGION_CONFIGS[tenant.region]["s3_bucket"]
async def upload_document(
self,
tenant_id: str,
document_id: str,
content: bytes
) -> str:
"""Upload document to tenant's regional storage."""
tenant = await self.tenant_service.get_tenant(tenant_id)
bucket = self.REGION_CONFIGS[tenant.region]["s3_bucket"]
# Path includes tenant for isolation
key = f"tenants/{tenant_id}/documents/{document_id}"
# Encrypt with tenant's key before upload
encrypted = await self.encrypt_for_tenant(tenant_id, content)
await self.s3.put_object(
Bucket=bucket,
Key=key,
Body=encrypted,
ServerSideEncryption="aws:kms",
SSEKMSKeyId=tenant.encryption_key_id
)
return f"s3://{bucket}/{key}"GDPR Consent Management#
# gdpr/consent.py
from datetime import datetime, timezone
class GDPRConsentService:
"""
Manages user consent for GDPR compliance.
"""
CONSENT_PURPOSES = [
"service_delivery", # Required for service
"analytics", # Product analytics
"marketing_email", # Marketing communications
"third_party_sharing", # Sharing with partners
]
async def record_consent(
self,
user_id: str,
tenant_id: str,
purpose: str,
granted: bool,
ip_address: str,
consent_text: str
) -> ConsentRecord:
"""
Record a consent decision.
Creates immutable audit record.
"""
record = ConsentRecord(
id=str(uuid.uuid4()),
user_id=user_id,
tenant_id=tenant_id,
purpose=purpose,
status="granted" if granted else "denied",
granted_at=datetime.now(timezone.utc) if granted else None,
ip_address=ip_address,
consent_text=consent_text,
consent_version="2024-01"
)
# Store in regional database
await self.db.execute(
"""
INSERT INTO consent_records
(id, user_id, tenant_id, purpose, status, granted_at,
ip_address, consent_text, consent_version, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
""",
record.id, record.user_id, record.tenant_id,
record.purpose, record.status, record.granted_at,
record.ip_address, record.consent_text,
record.consent_version, datetime.now(timezone.utc)
)
# Publish event for downstream systems
await self.events.publish("consent", {
"type": "consent.recorded",
"user_id": user_id,
"purpose": purpose,
"granted": granted
})
return record
async def check_consent(
self,
user_id: str,
tenant_id: str,
purpose: str
) -> bool:
"""Check if user has consented to a purpose."""
result = await self.db.fetchrow(
"""
SELECT status FROM consent_records
WHERE user_id = $1 AND tenant_id = $2 AND purpose = $3
ORDER BY created_at DESC
LIMIT 1
""",
user_id, tenant_id, purpose
)
return result and result["status"] == "granted"Data Export (Right to Portability)#
# gdpr/export.py
from datetime import datetime, timezone
class GDPRDataExporter:
"""
Exports user data for GDPR portability requests.
"""
async def export_user_data(
self,
user_id: str,
tenant_id: str
) -> DataExportResult:
"""
Export all user's personal data.
GDPR Article 20: Right to data portability
"""
export_id = str(uuid.uuid4())
# Collect data from all sources
data = {
"export_metadata": {
"export_id": export_id,
"exported_at": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"tenant_id": tenant_id
},
"profile": await self._export_profile(user_id, tenant_id),
"documents": await self._export_documents(user_id, tenant_id),
"comments": await self._export_comments(user_id, tenant_id),
"activity_history": await self._export_activity(user_id, tenant_id),
"consent_records": await self._export_consent(user_id, tenant_id),
}
# Package as JSON
export_json = json.dumps(data, indent=2, default=str)
# Also create ZIP with actual files
zip_buffer = await self._create_export_zip(user_id, tenant_id, data)
# Upload to tenant's regional storage
bucket = await self.router.get_storage_bucket(tenant_id)
export_key = f"exports/{tenant_id}/{user_id}/{export_id}.zip"
await self.s3.put_object(
Bucket=bucket,
Key=export_key,
Body=zip_buffer.getvalue()
)
# Generate download link (7 days is the SigV4 maximum; if signed
# with temporary role credentials it dies when they expire)
download_url = await self.s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": export_key},
ExpiresIn=604800 # 7 days
)
return DataExportResult(
export_id=export_id,
download_url=download_url,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
size_bytes=len(zip_buffer.getvalue())
)Deep Dive 4: Right to Deletion (Week 9, Day 4)#
"When a user requests deletion, how do you ensure all their data is removed from all those systems?"
"Deletion is one of the hardest compliance requirements. I'd implement a coordinated deletion workflow with verification."
Deletion Orchestration#
# gdpr/deletion.py
from datetime import datetime, timezone
class UserDeletionService:
"""
Orchestrates user data deletion across all systems.
"""
# Systems in deletion order (dependencies first)
DELETION_TARGETS = [
("search", "elasticsearch", 2), # Remove from search
("storage", "s3", 3), # Delete files
("analytics", "bigquery", 4), # Remove from analytics
("database", "postgresql", 10), # Primary DB after the copies
("cache", "redis", 11), # Cache last, so reads can't refill it
]
async def process_deletion_request(
self,
user_id: str,
tenant_id: str,
requested_by: str
) -> DeletionRequest:
"""
Process a GDPR deletion request.
Must complete within one month per GDPR (30-day internal target).
"""
request = DeletionRequest(
id=str(uuid.uuid4()),
user_id=user_id,
tenant_id=tenant_id,
requested_at=datetime.now(timezone.utc),
requested_by=requested_by,
deadline=datetime.now(timezone.utc) + timedelta(days=30),
status="pending"
)
# Store request
await self._save_request(request)
# Execute deletion workflow
try:
await self._execute_deletion(request)
# Verify deletion
verification = await self._verify_deletion(request)
if verification.all_verified:
request.status = "completed"
request.completed_at = datetime.now(timezone.utc)
else:
request.status = "partial"
request.issues = verification.issues
except Exception as e:
request.status = "failed"
request.error = str(e)
raise
finally:
await self._save_request(request)
await self._notify_user(request)
return request
async def _execute_deletion(self, request: DeletionRequest):
"""Execute deletion across all systems."""
for system_type, system_name, priority in sorted(
self.DELETION_TARGETS, key=lambda x: x[2]
):
target = DeletionTarget(
system_name=system_name,
system_type=system_type,
status="pending"
)
try:
executor = self.executors[system_name]
result = await executor.delete_user_data(
request.user_id,
request.tenant_id
)
target.status = "completed"
target.records_deleted = result.get("records_deleted", 0)
await self.audit.log(
action="deletion_executed",
system=system_name,
user_id=request.user_id,
records_deleted=target.records_deleted
)
except Exception as e:
target.status = "failed"
target.error = str(e)
raise
request.targets.append(target)
async def _verify_deletion(
self,
request: DeletionRequest
) -> VerificationResult:
"""Verify that deletion was successful."""
issues = []
for target in request.targets:
executor = self.executors[target.system_name]
still_exists = await executor.check_user_exists(
request.user_id,
request.tenant_id
)
if still_exists:
issues.append(f"Data still exists in {target.system_name}")
return VerificationResult(
all_verified=len(issues) == 0,
issues=issues
)
class PostgreSQLDeletionExecutor:
"""
Deletes user data from PostgreSQL.
"""
async def delete_user_data(
self,
user_id: str,
tenant_id: str
) -> dict:
"""Delete user and related data."""
records_deleted = 0
async with self.db.transaction():
# Delete from leaf tables first
# Comments (anonymize, keep content)
result = await self.db.execute(
"""
UPDATE comments
SET user_id = NULL, author_name = 'Deleted User'
WHERE user_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Activity logs (anonymize)
result = await self.db.execute(
"""
UPDATE activity_logs
SET user_id = 'DELETED', ip_address = 'DELETED'
WHERE user_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Documents (reassign ownership to admin or delete)
result = await self.db.execute(
"""
UPDATE documents
SET owner_id = (
SELECT id FROM users
WHERE tenant_id = $2 AND 'admin' = ANY(roles)
LIMIT 1
)
WHERE owner_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Consent records (keep anonymized for audit)
result = await self.db.execute(
"""
UPDATE consent_records
SET user_id = 'DELETED', ip_address = 'DELETED'
WHERE user_id = $1 AND tenant_id = $2
""",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
# Finally, delete user
result = await self.db.execute(
"DELETE FROM users WHERE id = $1 AND tenant_id = $2",
user_id, tenant_id
)
records_deleted += int(result.split()[-1])
return {"records_deleted": records_deleted}Deep Dive 5: Security Architecture (Week 9, Day 5)#
"Let's talk security. How do you protect this system, especially with multiple tenants?"
"Security is defense in depth with zero trust principles. Let me walk through the layers."
Security Layers#
- LAYER 1: EDGE SECURITY — CloudFront (CDN) → WAF → Rate Limiting → DDoS Protection
- OWASP Top 10 rules
- Bot detection
- IP reputation
- Geo-blocking (optional per tenant)
- LAYER 2: NETWORK SECURITY — VPC isolation:
- Public subnet: ALB only
- Private subnet: App servers
- Isolated subnet: Databases
- Security groups: Explicit allow only
- LAYER 3: APPLICATION SECURITY
- Authentication (JWT + MFA)
- Authorization (RBAC + tenant isolation)
- Input validation (Pydantic schemas)
- Output encoding
- CSRF protection
- LAYER 4: DATA SECURITY
- Encryption in transit (TLS 1.2+, 1.3 preferred)
- Encryption at rest (AES-256)
- Per-tenant encryption keys (enterprise)
- Secrets in Vault
- Data classification and handling
- LAYER 5: MONITORING & DETECTION
- Audit logging (all access)
- Anomaly detection
- SIEM integration
- Incident response automation
Authentication and Authorization#
# security/auth.py
from datetime import datetime, timezone
class AuthenticationService:
"""
Multi-tenant authentication service.
"""
async def authenticate(
self,
email: str,
password: str,
tenant_id: str,
ip_address: str
) -> AuthResult:
"""
Authenticate user within their tenant.
"""
# Rate limiting per IP + tenant
if await self._is_rate_limited(ip_address, tenant_id):
raise AuthError("Too many attempts")
# Find user in tenant
user = await self.db.fetchrow(
"""
SELECT id, email, password_hash, roles, mfa_enabled, status
FROM users
WHERE email = $1 AND tenant_id = $2
""",
email.lower(), tenant_id
)
if not user:
await self._record_failed_attempt(ip_address, tenant_id)
raise AuthError("Invalid credentials")
# Verify password before revealing anything about the account
if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
await self._record_failed_attempt(ip_address, tenant_id)
raise AuthError("Invalid credentials")
if user["status"] != "active":
raise AuthError("Account disabled")
# Create session
session_id = secrets.token_urlsafe(32)
result = AuthResult(
user_id=user["id"],
tenant_id=tenant_id,
roles=user["roles"],
mfa_required=user["mfa_enabled"],
session_id=session_id
)
# Audit log
await self.audit.log(
action="login_success",
user_id=user["id"],
tenant_id=tenant_id,
ip_address=ip_address
)
return result
async def create_jwt(self, auth_result: AuthResult) -> str:
"""Create JWT with tenant claims."""
signing_key = await self.secrets.get_secret("jwt/signing_key")
payload = {
"sub": auth_result.user_id,
"tenant_id": auth_result.tenant_id,
"roles": auth_result.roles,
"session_id": auth_result.session_id,
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=15)
}
return jwt.encode(payload, signing_key.value, algorithm="RS256")
class AuthorizationService:
"""
Multi-tenant authorization with RBAC.
"""
async def check_document_access(
self,
user_id: str,
tenant_id: str,
document_id: str,
required_permission: str
) -> bool:
"""
Check if user can access document.
Enforces:
1. Tenant isolation (user's tenant == document's tenant)
2. Role-based permission
3. Document-level permission
"""
# Get document
document = await self.db.fetchrow(
"""
SELECT tenant_id, owner_id, permissions
FROM documents
WHERE id = $1
""",
document_id
)
if not document:
return False
# CRITICAL: Tenant isolation check
if document["tenant_id"] != tenant_id:
await self.audit.log(
action="access_denied",
reason="tenant_mismatch",
user_id=user_id,
document_id=document_id
)
return False
# Check ownership
if document["owner_id"] == user_id:
return True
# Check document permissions
permissions = document["permissions"] or {}
user_permission = permissions.get(user_id)
if user_permission:
return self._has_permission(user_permission, required_permission)
# Check folder permissions (inherited)
# ... folder permission check logic ...
return FalseComprehensive Audit Logging#
# security/audit.py
from datetime import datetime, timezone
class AuditService:
"""
Comprehensive audit logging for compliance.
"""
async def log(
self,
action: str,
**context
):
"""
Log an audit event.
All access, modifications, and security events are logged.
"""
# Not get_current_tenant(): it raises when unset (e.g. failed logins),
# and those events must still be audited
tenant = _tenant_context.get()
event = AuditEvent(
id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc),
tenant_id=tenant.tenant_id if tenant else context.get("tenant_id"),
action=action,
actor_id=context.get("user_id"),
actor_type=context.get("actor_type", "user"),
resource_type=context.get("resource_type"),
resource_id=context.get("resource_id"),
ip_address=context.get("ip_address"),
user_agent=context.get("user_agent"),
details=context
)
# Write to regional audit log (immutable)
await self.db.execute(
"""
INSERT INTO audit_logs
(id, timestamp, tenant_id, action, actor_id, actor_type,
resource_type, resource_id, ip_address, user_agent, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
""",
event.id, event.timestamp, event.tenant_id, event.action,
event.actor_id, event.actor_type, event.resource_type,
event.resource_id, event.ip_address, event.user_agent,
json.dumps(event.details)
)
# Also stream to Kafka for real-time monitoring
await self.kafka.produce(
topic="audit_events",
key=event.tenant_id,
value=event.to_dict()
)Residency, deletion and security
Q1/3
UserDeletionServiceclears search, S3 and analytics before PostgreSQL (and the cache only after it). Why do the derived copies go before the primary DB?Answer & explanation
Answer: A
The derived copies (index, files, analytics) are removed while the primary record still says what belongs to the user. Delete the database first and any later failure leaves orphaned copies you can no longer locate.
Q2/3 Why does the PostgreSQL executor anonymize comments and consent records instead of deleting the rows?
Answer & explanation
Answer: C
Comments are part of shared conversations and consent records are compliance evidence, so the code nulls or replaces
user_idand IP instead of dropping rows. The design chooses anonymization to keep the audit trail, not because deletion is forbidden.Q3/3 An EU tenant uploads a contract through
RegionalDataRouter. Where does it end up?Answer & explanation
Answer: D
The router picks the bucket from the tenant's configured region, not the user's location, and the path includes the tenant id. Any hop through US infrastructure would violate the rule that EU tenant data never touches US systems.
Trade-off: 5,000 tenants on one PostgreSQL per region?
The platform grows from 500 to 5,000 tenants. API servers scale out easily, but each region's PostgreSQL won't. Would you shard by document id or by tenant id, and what does each choice cost?
One strong answer
- By tenant id: a tenant's rows stay on one shard, so its queries stay local and it can be backed up, restored or moved on its own. The cost: one huge tenant can make one shard hot.
- By document id: load spreads evenly, but every tenant's queries touch every shard, and per-tenant backup or deletion becomes a scatter across all of them.
- So shard by tenant, and give the largest tenants a dedicated shard.
Phase 5: Scaling and Edge Cases (5 minutes)#
"How would this system scale if we went from 500 to 5,000 tenants?"
Scaling Strategy#
"The architecture is designed to scale horizontally. Here's how each component scales:"
| Component | Current | 10x Scale | How |
|---|---|---|---|
| API Servers | 20 instances | 200 instances | Auto-scaling group |
| PostgreSQL | 2TB per region | Sharding | By tenant_id |
| Elasticsearch | ~3TB total | ~30TB total | Add nodes |
| S3 | 180TB | 1.8PB | Automatic |
| Redis | 50GB cluster | 500GB cluster | Add shards |
| Workers | 10 instances | 100 instances | Queue-based scale |
Key scaling decisions
- Database sharding by
tenant_id- Keeps tenant data together
- Enables tenant-level backup/restore
- Large tenants can get dedicated shards
- Search index per tenant (for large tenants)
- Avoids hot spots
- Enables tenant-specific tuning
- Easier to delete/migrate
- Add more regions as needed
- Japan region for Japanese customers
- Australia region for AU/NZ
- Each region is independent
Edge Cases#
"What edge cases should we handle?"
"Several important edge cases:"
| # | Edge case | Scenario | Handling |
|---|---|---|---|
| 1 | TENANT OFFBOARDING | Customer cancels subscription | 30-day grace period (can reactivate) · Export data to customer · Then complete deletion · Retain anonymized audit logs |
| 2 | LARGE FILE UPLOADS | Customer uploads 500MB presentation | Direct-to-S3 upload (presigned URL) · Chunked upload for resume · Async processing in background · Progress tracking |
| 3 | SEARCH INDEX CORRUPTION | Elasticsearch index gets corrupted | Detection: Scheduled consistency checks · Recovery: Rebuild from PostgreSQL · Blast radius: one tenant with a per-tenant index (enterprise), every tenant on that shared index (standard) · Automated healing with alerting |
| 4 | CROSS-TENANT SHARING (External links) | User shares document externally | Generate unique, expiring token · Token tied to document, not tenant context · Audit log records external access · Owner can revoke anytime |
| 5 | SSO PROVIDER OUTAGE | Customer's SSO is down | Fallback to email/password · Requires pre-configured backup auth · Audit log notes SSO bypass · Notify tenant admin |
| 6 | REGULATORY HOLD | Legal hold prevents deletion | Mark documents as "held" · Deletion requests queued · User notified of delay · Release when hold lifted |
Scaling and edge cases
Q1/3 Going from 500 to 5,000 tenants, why shard PostgreSQL by
tenant_idrather than by document id?Answer & explanation
Answer: B
Keeping a tenant on one shard means its queries stay local, it can be backed up or restored alone, and a huge tenant can move to a dedicated shard. It does not spread one tenant across shards; that is the trade-off dedicated shards address.
Q2/3 A user under a regulatory legal hold submits a GDPR deletion request. What does the design do?
Answer & explanation
Answer: C
Held documents are marked and deletion requests wait, with the user notified, until the hold is released. Deleting anyway would destroy evidence under hold, and a hidden copy would make the "deletion" a lie.
Q3/3 A customer's SSO provider goes down. How does the design keep their users working without hiding what happened?
Answer & explanation
Answer: A
The fallback only works if backup auth was configured beforehand, and every SSO bypass is logged with the tenant admin informed. Disabling auth or borrowing another tenant's identity provider would break both security and isolation.
Phase 6: Monitoring and Operations (5 minutes)#
"How would you monitor this system in production?"
Key Metrics#
Business metrics (per tenant; example enterprise tenant on custom contract limits)
| Metric | Value |
|---|---|
| Active users | 8,234/10,000 |
| Documents stored | 4.2M / 5M |
| Storage used | 720GB / 1TB |
| API calls today | 42K / 100K |
System health (per region)
| Metric | Value |
|---|---|
| API latency p99 | 180ms (< 200ms) |
| Search latency p99 | 320ms (< 500ms) |
| Error rate | 0.02% (< 0.1%) |
| Throughput | 800 req/s |
Security metrics
| Metric | Value |
|---|---|
| Failed login attempts/hour | 234 |
| Cross-tenant access attempts | 0 (should be 0!) |
| WAF blocked requests | 1,234/hour |
| MFA adoption | 62% |
Compliance metrics
| Metric | Value |
|---|---|
| Pending deletion requests | 3 |
| Avg deletion completion time | 4.2 days |
| Data export requests/week | 12 |
| Audit log retention | 7 years |
Alerting Strategy#
CRITICAL (PagerDuty - immediate response)
- Cross-tenant data access detected
- Database replication lag > 1 minute
- Error rate > 1%
- Any region unreachable
- Security incident detected
WARNING (Slack - business hours)
- Tenant approaching quota limits
- Search latency > 400ms p99
- Deletion request approaching deadline
- Failed login spike for tenant
- Certificate expiring < 30 days
INFO (Dashboard only)
- New tenant onboarded
- Large file upload completed
- Scheduled maintenance
- Feature flag changed
Operating a multi-tenant platform
Q1/3 "Cross-tenant data access detected" pages immediately, with a target of zero. Why page on a single occurrence?
Answer & explanation
Answer: B
Most metrics have budgets. Isolation doesn't: one tenant seeing another's data is a security incident, whatever the count.
Q2/3 Why break latency, errors and usage down per tenant instead of watching only global numbers?
Answer & explanation
Answer: B
A single large tenant hammering the system, or one tenant's broken region, can sit inside a healthy-looking global average. Per-tenant views are what make noisy neighbours visible.
Q3/3 Why is "deletion request approaching deadline" an alert at all?
Answer & explanation
Answer: B
The right to erasure is a legal obligation with a clock. Tracking time-to-deadline turns a compliance risk into an operational signal.
Interview Conclusion#
"This is a comprehensive design. You've covered multi-tenancy, compliance, and security thoroughly. Any final thoughts?"
"A few things I'd prioritize for implementation:
- Start with tenant isolation - Get this right first, it's the foundation
- Build compliance into the architecture - Retrofitting GDPR is painful
- Invest in observability early - Per-tenant metrics from day one
- Plan for enterprise features - Dedicated resources, custom encryption
- Security as code - Infrastructure as code, security policies as code
The key insight is that multi-tenant SaaS is harder than single-tenant because every feature needs to consider isolation, fairness, and compliance from the start."
"Excellent. Thanks for walking through this with me."
Summary: Week 9 Concepts Applied#
Concepts by Day#
| Day | Topic | Application in Design |
|---|---|---|
| Day 1 | Tenant Isolation | Multi-layer isolation (app, DB RLS, storage paths), enterprise dedicated resources |
| Day 2 | Noisy Neighbor | Per-tenant quotas, rate limiting, fair scheduling, query guards |
| Day 3 | Data Residency | Regional data planes, no cross-region data flow, consent management |
| Day 4 | Right to Deletion | Deletion orchestration, verification, anonymization vs delete |
| Day 5 | Security | Defense in depth, zero trust, encryption layers, audit logging |
Code Patterns Demonstrated#
- 1. TENANT CONTEXT MANAGEMENT
- Immutable
TenantContextdataclass ContextVarfor thread-safe propagation- Middleware sets context from the verified JWT (subdomain must match)
- All services use
get_current_tenant()
- 2. REPOSITORY PATTERN WITH ISOLATION
TenantAwareRepositorybase class- Auto-adds
tenant_idto all queries - RLS as database-level backup
- No raw SQL without tenant filter
- 3. REGIONAL DATA ROUTING
RegionalDataRouterfor all data ops- Tenant config determines region
- Each region has full stack
- Global control plane for metadata only
- 4. DELETION ORCHESTRATION
DeletionServicecoordinates- System-specific executors
- Verification confirms deletion
- Audit trail survives deletion
- 5. DEFENSE IN DEPTH SECURITY
- Edge → Network → App → Data layers
- Each layer assumes others might fail
- Zero trust between services
- Comprehensive audit logging
Self-Assessment Checklist#
After studying this capstone, you should be able to:
- Design multi-tenant systems with proper data isolation
- Implement Row-Level Security in PostgreSQL
- Build per-tenant rate limiting and quota systems
- Architect for data residency requirements
- Handle GDPR consent, export, and deletion
- Design defense-in-depth security architecture
- Implement comprehensive audit logging
- Scale multi-tenant systems horizontally
- Handle edge cases like tenant offboarding
- Monitor multi-tenant systems with per-tenant metrics
This capstone integrates all concepts from Week 9: Multi-Tenancy, Security, and Compliance. Use this as a template for approaching enterprise SaaS system design interviews.
this lesson in shorts · 1
play them all →One idea from this lesson in about a minute each, made for your phone.
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.
Post-Incident Review on the Atlassian April 2022 outage
Sri Viswanath · 2022- What
- Atlassian's review of the outage in which 775 customers lost access to their products for up to 14 days.
- Key idea
- A deletion script accepted IDs without confirming whether they were apps or whole sites, so sites were deleted; restoring them from shared multi-tenant storage took up to 14 days.
- Why here
- The capstone's isolation and deletion deep dives in one incident: tenant-scoped restore must be designed in, not improvised.
Unified Grid: How We Re-Architected Slack for Our Largest Customers
Ian Hoffman, Mike Demmer · 2024- What
- How Slack moved Enterprise Grid customers from a workspace-centric to an organisation-centric architecture.
- Key idea
- Users in many workspaces had to switch views and saw sync bugs, so data access was re-oriented around the user and organisation instead of one workspace.
- Why here
- The capstone's tenant model: decide early whether the tenant is the org or the sub-unit, because changing it later is a re-architecture.
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.
