技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
backend-multi-tenancy【数据与研究】Use this skill when the user says 'multi-tenancy', 'SaaS', 'tenant isolation', 'row-level security', 'DB per tenant', 'schema per tenant', 'tenant provisioning', 'tenant migration', 'multi-tenant database', 'tenant context'. This skill implements tenant isolation strategies: row-level, schema-per-tenant, and DB-per-tenant with provisioning and migrat…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Implement tenant isolation in a SaaS application using row-level, schema-per-tenant, or DB-per-tenant strategies with automated provisioning and safe migrations. The core challenge is guaranteeing that no tenant can access another tenant's data while balancing operational complexity, cost, and compliance requirements.
Is regulatory compliance required (GDPR/SOC 2/HIPAA)?
|-- YES --> Do tenants require full data separation?
| |-- YES --> DB-per-tenant (highest isolation)
| |-- NO --> Schema-per-tenant
|-- NO --> Expected tenant count?
|-- < 100, B2B --> Schema-per-tenant
|-- >= 100, B2C --> Row-level (shared database)
|-- Mixed (B2B+B2C) --> Hybrid: row-level for B2C, schema for B2B
| Tier | Isolation | Database | Provisioning | Backup |
|---|---|---|---|---|
| Free | Row-level | Shared | Auto (self) | Global |
| Pro | Schema | Shared | Auto (self) | Global |
| Enterprise | DB | Dedicated | Manual | Per-tenant |
Exact user phrases: "multi-tenancy", "SaaS", "tenant isolation", "row-level security", "DB per tenant", "schema per tenant", "tenant provisioning", "tenant migration", "multi-tenant database", "tenant context".
Tenant isolation configuration or implementation plan. No file unless requested.
Strategy: {Row-level|Schema-per-tenant|DB-per-tenant}
Isolation Level: {shared|semi-isolated|fully-isolated}
Provisioning: {automated|manual}
Migration: {shared schema|per-tenant}
4 lines per isolation strategy. 20 lines for full plan.
| Strategy | Isolation | Complexity | Cost | Best For |
|---|---|---|---|---|
| Row-level | Lowest | Low | Low | B2C, many small tenants |
| Schema-per-tenant | Medium | Medium | Medium | B2B, medium tenants |
| DB-per-tenant | Highest | High | High | Enterprise, regulated |
// Middleware extracts tenant from auth token
async function tenantMiddleware(req, res, next) {
const token = decodeJWT(req.headers.authorization);
req.tenantId = token.tenant_id;
// Validate tenant is active and not suspended
const tenant = await tenantCache.get(req.tenantId);
if (!tenant || tenant.status !== 'active') {
return res.status(403).json({ error: 'tenant_inactive' });
}
// Set in async local storage for ORM access
asyncLocalStorage.run({ tenantId: req.tenantId, tenant }, next);
}
// Prisma example: global middleware appends tenant filter
prisma.$use(async (params, next) => {
const tenantId = asyncLocalStorage.getStore()?.tenantId;
if (tenantId && params.model && !params.args.where?.tenantId) {
params.args.where = { ...params.args.where, tenantId };
}
return next(params);
});
-- For schema-per-tenant
CREATE SCHEMA IF NOT EXISTS tenant_{id};
-- Apply migrations to tenant schema
SELECT migrate_tenant('tenant_{id}');
-- Seed default data for new tenant
INSERT INTO tenant_{id}.settings (key, value) VALUES ('theme', 'default');
WHERE tenant_id = ? on every query.# For shared schema: add nullable column, backfill, then NOT NULL
# For per-tenant: iterate tenants, migrate one by one
# Use a migration orchestration table to track per-tenant progress
// Per-tenant migration runner
async function migrateTenants(migrationName: string, migrationFn: (schema: string) => Promise<void>) {
const tenants = await tenantRepository.listActive();
// Use batch processing with concurrency limit
const BATCH_SIZE = 10;
for (let i = 0; i < tenants.length; i += BATCH_SIZE) {
const batch = tenants.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (tenant) => {
const schema = `tenant_${tenant.id}`;
try {
await migrationFn(schema);
await migrationTracker.recordSuccess(tenant.id, migrationName);
logger.info('Migration applied', { tenant: tenant.id, migration: migrationName });
} catch (err) {
await migrationTracker.recordFailure(tenant.id, migrationName, err);
logger.error('Migration failed', { tenant: tenant.id, migration: migrationName, error: err });
}
})
);
}
}
class TenantRateLimiter {
async checkLimit(tenantId: string): Promise<boolean> {
const key = `ratelimit:tenant:${tenantId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.pexpire(key, 1000); // 1 second window
}
return current <= 1000; // max 1000 requests/second per tenant
}
}
-- Enable RLS on tables
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Create policy that uses session setting
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::UUID);
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::UUID);
-- Application sets tenant context at connection start
SET app.tenant_id = 'uuid-here';
// Type-safe tenant context
class TenantContext {
constructor(id, slug, plan, features, config) {
this.id = id;
this.slug = slug;
this.plan = plan;
this.features = features;
this.config = config;
}
}
// Usage: tenantContext.get() throughout application
const tenantContext = {
get: () => asyncLocalStorage.getStore(),
require: () => {
const ctx = asyncLocalStorage.getStore();
if (!ctx) throw new Error('No tenant context');
return ctx;
}
};
# Python example: per-tenant connection pool registry
class TenantPoolRegistry:
def __init__(self):
self.pools = {}
self.lock = Lock()
def get_pool(self, tenant_id: str) -> Pool:
if tenant_id not in self.pools:
config = self.tenant_config[tenant_id]
self.pools[tenant_id] = Pool(
dsn=config['database_url'],
min_size=2,
max_size=10,
)
return self.pools[tenant_id]
def remove_pool(self, tenant_id: str):
with self.lock:
if tenant_id in self.pools:
self.pools[tenant_id].close()
del self.pools[tenant_id]
// Always pass tenant context to background jobs
interface JobPayload {
tenantId: string;
data: Record<string, unknown>;
}
class TenantAwareWorker {
async process(job: JobPayload, handler: (ctx: TenantContext) => Promise<void>) {
const tenant = await tenantCache.get(job.tenantId);
if (!tenant || tenant.status !== 'active') {
logger.warn('Skipping job for inactive tenant', { tenantId: job.tenantId });
return;
}
await asyncLocalStorage.run({ tenantId: tenant.id, tenant }, () => handler(tenant));
}
}
| Concern | Practice |
|---|---|
| Noisy neighbor | One tenant's heavy query degrades everyone. Rate limit per tenant, monitor top tenants |
| Schema count bloat | PostgreSQL metadata cache degrades >5000 schemas. Archive old tenants |
| Connection pooling | DB-per-tenant: use PgBouncer/RDS Proxy. Row-level: single pool with tenant_id column |
| Backup strategy | DB-per-tenant: staggered backups. Row-level: global backup with per-tenant extract capability |
| Tenant migration | Schema-per-tenant → DB-per-tenant requires data export/import. Plan for migration |
| Monitoring | Per-tenant metrics: query latency, error rate, request count, storage usage |
| Risk | Mitigation |
|---|---|
| Cross-tenant data access | Row-Level Security as defense-in-depth. Never rely on app-layer alone |
| Tenant ID in URL | Use auth token, never URL params for tenant ID. Validate token claims |
| Background job leaks | Always pass tenant context in job payload, validate before processing |
| Shared cache poisoning | Prefix all cache keys with tenant ID |
| Rate limit bypass | Apply rate limits at gateway (per IP) AND application (per tenant) |
| Approach | Multi-Tenancy | Single-Tenant | Hybrid |
|---|---|---|---|
| Cost efficiency | High (shared infra) | Low (per-customer infra) | Medium |
| Operational overhead | Medium (one deployment) | High (N deployments) | Medium-High |
| Isolation guarantee | Configurable | Absolute | Tier-dependent |
| Upgrade velocity | Fast (one deployment) | Slow (per-customer) | Tier-dependent |
| Compliance scope | Broad (shared audit) | Per-customer | Tier-dependent |
tenant_id columns. Query planner overhead is negligible with proper indexes. Risk of "noisy neighbor" where one tenant's heavy query degrades performance for all.ANALYZE and VACUUM run per-schema. Connection pooling works per-database. Schema count above ~5000 may cause metadata bloat in PostgreSQL.ALTER TABLE ... ENABLE ROW LEVEL SECURITY; CREATE POLICY ... USING (tenant_id = current_setting('app.tenant_id')::UUID)django-tenants package for schema-per-tenant, django-simple-multitenant for row-level.acts_as_tenant gem for row-level, apartment gem for schema-per-tenant.spatie/laravel-multitenancy for row-level, stancl/tenancy for schema-per-tenant.No artifact produced unless requested. Next skill: bff-pattern — create tenant-specific BFFs for different client types. Carry forward: isolation strategy, tenant context mechanism, provisioning pipeline.
// Express.js tenant context middleware
const asyncLocalStorage = require('async_hooks').AsyncLocalStorage;
const tenantStorage = new AsyncLocalStorage();
function tenantMiddleware(req, res, next) {
const tenantId = extractTenantId(req);
const tenantConfig = getTenantConfig(tenantId);
if (!tenantConfig) {
return res.status(401).json({ error: 'Invalid tenant' });
}
const context = {
tenantId,
tenantConfig,
userId: req.user?.id,
requestId: req.id,
};
tenantStorage.run(context, () => {
req.tenant = context;
next();
});
}
function extractTenantId(req) {
// Priority: header > subdomain > query param
return req.headers['x-tenant-id']
|| req.subdomains[0]
|| req.query.tenant_id;
}
function getCurrentTenant() {
return tenantStorage.getStore();
}
function requireTenantAccess(requiredRoles = []) {
return (req, res, next) => {
const tenant = getCurrentTenant();
if (!tenant) {
return res.status(401).json({ error: 'No tenant context' });
}
if (requiredRoles.length > 0) {
const userRoles = tenant.tenantConfig.roles?.[tenant.userId] || [];
const hasRole = requiredRoles.some(r => userRoles.includes(r));
if (!hasRole) {
return res.status(403).json({ error: 'Insufficient permissions for this tenant' });
}
}
next();
};
}
-- Enable RLS on tables
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
-- Create a secure function to get current tenant
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS TEXT
LANGUAGE SQL STABLE
AS $
SELECT current_setting('app.tenant_id', TRUE)::TEXT;
$;
-- Row-level security policies
CREATE POLICY tenant_isolation_orders ON orders
FOR ALL
USING (tenant_id = current_tenant_id());
CREATE POLICY tenant_isolation_customers ON customers
FOR ALL
USING (tenant_id = current_tenant_id());
-- Set tenant context per session
-- Called from connection pool middleware
SELECT set_config('app.tenant_id', 'tenant-123', TRUE);
from typing import List
import psycopg2
class TenantMigrationRunner:
def __init__(self, conn_string: str):
self.conn_string = conn_string
def get_all_tenants(self) -> List[str]:
conn = psycopg2.connect(self.conn_string)
cur = conn.cursor()
cur.execute("SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'")
tenants = [row[0] for row in cur.fetchall()]
cur.close()
conn.close()
return tenants
def run_migration_for_tenant(self, tenant_schema: str, migration_sql: str):
conn = psycopg2.connect(self.conn_string)
cur = conn.cursor()
cur.execute(f"SET search_path TO {tenant_schema}")
cur.execute(migration_sql)
conn.commit()
cur.close()
conn.close()
def run_migration_all(self, migration_sql: str, tenants: List[str] = None):
if not tenants:
tenants = self.get_all_tenants()
for tenant in tenants:
print(f"Running migration on {tenant}")
self.run_migration_for_tenant(tenant, migration_sql)
def add_new_tenant(self, tenant_id: str, base_schema: str = "public"):
conn = psycopg2.connect(self.conn_string)
cur = conn.cursor()
schema_name = f"tenant_{tenant_id}"
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {schema_name}")
cur.execute(f"SET search_path TO {schema_name}")
# Clone base schema tables
cur.execute(f"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = '{base_schema}' AND table_type = 'BASE TABLE'
""")
tables = cur.fetchall()
for (table,) in tables:
cur.execute(f"CREATE TABLE {schema_name}.{table} (LIKE {base_schema}.{table} INCLUDING ALL)")
conn.commit()
cur.close()
conn.close()
print(f"Tenant {tenant_id} provisioned in schema {schema_name}")
What are the tenant requirements?
├── Small tenants (< 100 users), simple needs
│ └── Shared database with RLS
│ ├── Lowest cost, simplest operations
│ ├── Tenants share DB resources
│ └── RLS ensures data separation
│
├── Medium tenants, compliance needs (SOC2, HIPAA)
│ └── Schema-per-tenant
│ ├── Full data isolation in separate schemas
│ ├── Common migration per tenant
│ ├── Separate backups per tenant
│ └── More complex connection management
│
├── Large/enterprise tenants
│ └── Database-per-tenant
│ ├── Complete resource isolation
│ ├── Independent scaling and backup
│ ├── Highest cost due to per-DB overhead
│ └── Suitable for multi-region deployment
│
└── Mixed tenant tiers
└── Hybrid: RLS for small, schema for medium, DB for large
├── Migrate tenants to higher isolation on tier upgrade
├── Monitoring-driven isolation decisions
└── Unified tenant management across tiers
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Tenant ID from user input | Tampering allows cross-tenant access | Extract from auth token only |
| Application-level isolation only | Single bug exposes all tenant data | Defense-in-depth: app + DB-level isolation |
| Shared sequences across tenants | Predictable IDs reveal other tenant data | UUIDs or per-tenant sequences |
| Global rate limiting | One noisy tenant DoS-es all others | Per-tenant rate limits + global cap |
| One-size backup strategy | Enterprise tenants need faster RPO | Tiered backup policies per tenant plan |
| No tenant-aware connection pooling | Connections grow linearly with tenants | Use pgbouncer with pool size per tenant |