multi-tenancy

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Multi-Tenancy

多租户架构

Build SaaS apps that serve multiple organizations securely.
构建可安全服务多个组织的SaaS应用。

When to Use This Skill

适用场景

  • B2B SaaS applications
  • White-label platforms
  • Enterprise software
  • Any app serving multiple organizations
  • B2B SaaS应用程序
  • 白标平台
  • 企业软件
  • 任何服务多个组织的应用

Isolation Models

隔离模型

1. Shared Database, Shared Schema (Recommended for most)

1. 共享数据库、共享Schema(推荐多数场景使用)

┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  users: id, tenant_id, email, ...                   │
│  orders: id, tenant_id, user_id, ...                │
│  products: id, tenant_id, name, ...                 │
│                                                     │
│  All tables have tenant_id column                   │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  users: id, tenant_id, email, ...                   │
│  orders: id, tenant_id, user_id, ...                │
│  products: id, tenant_id, name, ...                 │
│                                                     │
│  All tables have tenant_id column                   │
└─────────────────────────────────────────────────────┘

2. Shared Database, Schema per Tenant

2. 共享数据库、单租户独立Schema

┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  tenant_acme.users                                  │
│  tenant_acme.orders                                 │
│  tenant_globex.users                                │
│  tenant_globex.orders                               │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  tenant_acme.users                                  │
│  tenant_acme.orders                                 │
│  tenant_globex.users                                │
│  tenant_globex.orders                               │
└─────────────────────────────────────────────────────┘

3. Database per Tenant (Enterprise)

3. 单租户独立数据库(企业级)

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  acme_db     │  │  globex_db   │  │  initech_db  │
│              │  │              │  │              │
│  users       │  │  users       │  │  users       │
│  orders      │  │  orders      │  │  orders      │
└──────────────┘  └──────────────┘  └──────────────┘
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  acme_db     │  │  globex_db   │  │  initech_db  │
│              │  │              │  │              │
│  users       │  │  users       │  │  users       │
│  orders      │  │  orders      │  │  orders      │
└──────────────┘  └──────────────┘  └──────────────┘

TypeScript Implementation

TypeScript实现

Tenant Context

租户上下文

typescript
// tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';

interface TenantContext {
  tenantId: string;
  tenantSlug: string;
  plan: 'free' | 'pro' | 'enterprise';
  features: string[];
}

const tenantStorage = new AsyncLocalStorage<TenantContext>();

export function getTenant(): TenantContext {
  const tenant = tenantStorage.getStore();
  if (!tenant) {
    throw new Error('No tenant context available');
  }
  return tenant;
}

export function runWithTenant<T>(tenant: TenantContext, fn: () => T): T {
  return tenantStorage.run(tenant, fn);
}

export { tenantStorage, TenantContext };
typescript
// tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';

interface TenantContext {
  tenantId: string;
  tenantSlug: string;
  plan: 'free' | 'pro' | 'enterprise';
  features: string[];
}

const tenantStorage = new AsyncLocalStorage<TenantContext>();

export function getTenant(): TenantContext {
  const tenant = tenantStorage.getStore();
  if (!tenant) {
    throw new Error('No tenant context available');
  }
  return tenant;
}

export function runWithTenant<T>(tenant: TenantContext, fn: () => T): T {
  return tenantStorage.run(tenant, fn);
}

export { tenantStorage, TenantContext };

Tenant Middleware

租户中间件

typescript
// tenant-middleware.ts
import { Request, Response, NextFunction } from 'express';
import { runWithTenant, TenantContext } from './tenant-context';

interface TenantMiddlewareOptions {
  headerName?: string;
  subdomainExtract?: boolean;
  pathExtract?: boolean;
}

export function tenantMiddleware(options: TenantMiddlewareOptions = {}) {
  const { headerName = 'x-tenant-id', subdomainExtract = true } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    let tenantId: string | undefined;

    // Strategy 1: Header
    tenantId = req.headers[headerName.toLowerCase()] as string;

    // Strategy 2: Subdomain (acme.yourapp.com)
    if (!tenantId && subdomainExtract) {
      const host = req.hostname;
      const subdomain = host.split('.')[0];
      if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
        tenantId = subdomain;
      }
    }

    // Strategy 3: Path (/t/acme/dashboard)
    if (!tenantId && options.pathExtract) {
      const match = req.path.match(/^\/t\/([^/]+)/);
      if (match) {
        tenantId = match[1];
      }
    }

    // Strategy 4: User's default tenant (from JWT)
    if (!tenantId && req.user?.defaultTenantId) {
      tenantId = req.user.defaultTenantId;
    }

    if (!tenantId) {
      return res.status(400).json({ error: 'Tenant not specified' });
    }

    // Load tenant from database
    const tenant = await db.tenants.findUnique({
      where: { id: tenantId },
      select: { id: true, slug: true, plan: true, features: true },
    });

    if (!tenant) {
      return res.status(404).json({ error: 'Tenant not found' });
    }

    // Check user has access to tenant
    if (req.user) {
      const membership = await db.tenantMemberships.findFirst({
        where: { userId: req.user.id, tenantId: tenant.id },
      });
      if (!membership) {
        return res.status(403).json({ error: 'Access denied to tenant' });
      }
      req.userRole = membership.role;
    }

    // Run request with tenant context
    runWithTenant(
      {
        tenantId: tenant.id,
        tenantSlug: tenant.slug,
        plan: tenant.plan,
        features: tenant.features,
      },
      () => next()
    );
  };
}
typescript
// tenant-middleware.ts
import { Request, Response, NextFunction } from 'express';
import { runWithTenant, TenantContext } from './tenant-context';

interface TenantMiddlewareOptions {
  headerName?: string;
  subdomainExtract?: boolean;
  pathExtract?: boolean;
}

export function tenantMiddleware(options: TenantMiddlewareOptions = {}) {
  const { headerName = 'x-tenant-id', subdomainExtract = true } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    let tenantId: string | undefined;

    // Strategy 1: Header
    tenantId = req.headers[headerName.toLowerCase()] as string;

    // Strategy 2: Subdomain (acme.yourapp.com)
    if (!tenantId && subdomainExtract) {
      const host = req.hostname;
      const subdomain = host.split('.')[0];
      if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
        tenantId = subdomain;
      }
    }

    // Strategy 3: Path (/t/acme/dashboard)
    if (!tenantId && options.pathExtract) {
      const match = req.path.match(/^\/t\/([^/]+)/);
      if (match) {
        tenantId = match[1];
      }
    }

    // Strategy 4: User's default tenant (from JWT)
    if (!tenantId && req.user?.defaultTenantId) {
      tenantId = req.user.defaultTenantId;
    }

    if (!tenantId) {
      return res.status(400).json({ error: 'Tenant not specified' });
    }

    // Load tenant from database
    const tenant = await db.tenants.findUnique({
      where: { id: tenantId },
      select: { id: true, slug: true, plan: true, features: true },
    });

    if (!tenant) {
      return res.status(404).json({ error: 'Tenant not found' });
    }

    // Check user has access to tenant
    if (req.user) {
      const membership = await db.tenantMemberships.findFirst({
        where: { userId: req.user.id, tenantId: tenant.id },
      });
      if (!membership) {
        return res.status(403).json({ error: 'Access denied to tenant' });
      }
      req.userRole = membership.role;
    }

    // Run request with tenant context
    runWithTenant(
      {
        tenantId: tenant.id,
        tenantSlug: tenant.slug,
        plan: tenant.plan,
        features: tenant.features,
      },
      () => next()
    );
  };
}

Tenant-Scoped Queries

租户范围查询

typescript
// tenant-prisma.ts
import { PrismaClient } from '@prisma/client';
import { getTenant } from './tenant-context';

// Extend Prisma with automatic tenant filtering
export function createTenantPrisma(prisma: PrismaClient) {
  return prisma.$extends({
    query: {
      $allModels: {
        async findMany({ model, operation, args, query }) {
          // Auto-add tenant filter
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async findFirst({ model, operation, args, query }) {
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async findUnique({ model, operation, args, query }) {
          // For unique queries, verify tenant after fetch
          const result = await query(args);
          if (result && result.tenantId !== getTenant().tenantId) {
            return null; // Hide cross-tenant data
          }
          return result;
        },
        async create({ model, operation, args, query }) {
          // Auto-set tenant on create
          args.data = { ...args.data, tenantId: getTenant().tenantId };
          return query(args);
        },
        async update({ model, operation, args, query }) {
          // Ensure update is scoped to tenant
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async delete({ model, operation, args, query }) {
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
      },
    },
  });
}

// Usage
const tenantDb = createTenantPrisma(prisma);

// These are automatically scoped to current tenant
const users = await tenantDb.user.findMany();
const order = await tenantDb.order.create({ data: { ... } });
typescript
// tenant-prisma.ts
import { PrismaClient } from '@prisma/client';
import { getTenant } from './tenant-context';

// Extend Prisma with automatic tenant filtering
export function createTenantPrisma(prisma: PrismaClient) {
  return prisma.$extends({
    query: {
      $allModels: {
        async findMany({ model, operation, args, query }) {
          // Auto-add tenant filter
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async findFirst({ model, operation, args, query }) {
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async findUnique({ model, operation, args, query }) {
          // For unique queries, verify tenant after fetch
          const result = await query(args);
          if (result && result.tenantId !== getTenant().tenantId) {
            return null; // Hide cross-tenant data
          }
          return result;
        },
        async create({ model, operation, args, query }) {
          // Auto-set tenant on create
          args.data = { ...args.data, tenantId: getTenant().tenantId };
          return query(args);
        },
        async update({ model, operation, args, query }) {
          // Ensure update is scoped to tenant
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async delete({ model, operation, args, query }) {
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
      },
    },
  });
}

// Usage
const tenantDb = createTenantPrisma(prisma);

// These are automatically scoped to current tenant
const users = await tenantDb.user.findMany();
const order = await tenantDb.order.create({ data: { ... } });

Per-Tenant Configuration

单租户配置

typescript
// tenant-config.ts
interface TenantConfig {
  branding: {
    logo?: string;
    primaryColor?: string;
    companyName?: string;
  };
  features: {
    maxUsers: number;
    maxStorage: number;
    apiAccess: boolean;
    sso: boolean;
  };
  integrations: {
    slack?: { webhookUrl: string };
    stripe?: { customerId: string };
  };
}

class TenantConfigService {
  private cache = new Map<string, TenantConfig>();

  async getConfig(tenantId: string): Promise<TenantConfig> {
    if (this.cache.has(tenantId)) {
      return this.cache.get(tenantId)!;
    }

    const tenant = await db.tenants.findUnique({
      where: { id: tenantId },
      include: { config: true },
    });

    const config = this.buildConfig(tenant);
    this.cache.set(tenantId, config);
    return config;
  }

  private buildConfig(tenant: Tenant): TenantConfig {
    // Merge plan defaults with tenant overrides
    const planDefaults = PLAN_CONFIGS[tenant.plan];
    return {
      branding: { ...tenant.config?.branding },
      features: { ...planDefaults.features, ...tenant.config?.features },
      integrations: { ...tenant.config?.integrations },
    };
  }

  invalidateCache(tenantId: string) {
    this.cache.delete(tenantId);
  }
}
typescript
// tenant-config.ts
interface TenantConfig {
  branding: {
    logo?: string;
    primaryColor?: string;
    companyName?: string;
  };
  features: {
    maxUsers: number;
    maxStorage: number;
    apiAccess: boolean;
    sso: boolean;
  };
  integrations: {
    slack?: { webhookUrl: string };
    stripe?: { customerId: string };
  };
}

class TenantConfigService {
  private cache = new Map<string, TenantConfig>();

  async getConfig(tenantId: string): Promise<TenantConfig> {
    if (this.cache.has(tenantId)) {
      return this.cache.get(tenantId)!;
    }

    const tenant = await db.tenants.findUnique({
      where: { id: tenantId },
      include: { config: true },
    });

    const config = this.buildConfig(tenant);
    this.cache.set(tenantId, config);
    return config;
  }

  private buildConfig(tenant: Tenant): TenantConfig {
    // Merge plan defaults with tenant overrides
    const planDefaults = PLAN_CONFIGS[tenant.plan];
    return {
      branding: { ...tenant.config?.branding },
      features: { ...planDefaults.features, ...tenant.config?.features },
      integrations: { ...tenant.config?.integrations },
    };
  }

  invalidateCache(tenantId: string) {
    this.cache.delete(tenantId);
  }
}

Database Schema

数据库Schema

sql
-- Tenants table
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug VARCHAR(50) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  plan VARCHAR(50) DEFAULT 'free',
  features TEXT[] DEFAULT '{}',
  config JSONB DEFAULT '{}',
  created_at TIMESTAMP DEFAULT NOW()
);

-- Users belong to tenants via memberships
CREATE TABLE tenant_memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
  role VARCHAR(50) DEFAULT 'member',
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

-- All data tables have tenant_id
CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  user_id UUID REFERENCES users(id),
  -- ... other columns
  created_at TIMESTAMP DEFAULT NOW()
);

-- Index for tenant queries
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

-- Row Level Security (optional, extra protection)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
sql
-- Tenants table
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug VARCHAR(50) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  plan VARCHAR(50) DEFAULT 'free',
  features TEXT[] DEFAULT '{}',
  config JSONB DEFAULT '{}',
  created_at TIMESTAMP DEFAULT NOW()
);

-- Users belong to tenants via memberships
CREATE TABLE tenant_memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
  role VARCHAR(50) DEFAULT 'member',
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

-- All data tables have tenant_id
CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  user_id UUID REFERENCES users(id),
  -- ... other columns
  created_at TIMESTAMP DEFAULT NOW()
);

-- Index for tenant queries
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

-- Row Level Security (optional, extra protection)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

Python Implementation

Python实现

python
undefined
python
undefined

tenant_context.py

tenant_context.py

from contextvars import ContextVar from dataclasses import dataclass from typing import Optional
@dataclass class TenantContext: tenant_id: str tenant_slug: str plan: str features: list[str]
_tenant_context: ContextVar[Optional[TenantContext]] = ContextVar( "tenant_context", default=None )
def get_tenant() -> TenantContext: tenant = _tenant_context.get() if not tenant: raise RuntimeError("No tenant context") return tenant
def set_tenant(tenant: TenantContext): return _tenant_context.set(tenant)
undefined
from contextvars import ContextVar from dataclasses import dataclass from typing import Optional
@dataclass class TenantContext: tenant_id: str tenant_slug: str plan: str features: list[str]
_tenant_context: ContextVar[Optional[TenantContext]] = ContextVar( "tenant_context", default=None )
def get_tenant() -> TenantContext: tenant = _tenant_context.get() if not tenant: raise RuntimeError("No tenant context") return tenant
def set_tenant(tenant: TenantContext): return _tenant_context.set(tenant)
undefined

FastAPI Middleware

FastAPI中间件

python
undefined
python
undefined

tenant_middleware.py

tenant_middleware.py

from fastapi import Request, HTTPException from starlette.middleware.base import BaseHTTPMiddleware
class TenantMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): tenant_id = request.headers.get("x-tenant-id")
    if not tenant_id:
        # Try subdomain
        host = request.headers.get("host", "")
        subdomain = host.split(".")[0]
        if subdomain not in ["www", "app", "api"]:
            tenant_id = subdomain

    if not tenant_id:
        raise HTTPException(400, "Tenant not specified")

    tenant = await db.tenants.find_unique(where={"id": tenant_id})
    if not tenant:
        raise HTTPException(404, "Tenant not found")

    token = set_tenant(TenantContext(
        tenant_id=tenant.id,
        tenant_slug=tenant.slug,
        plan=tenant.plan,
        features=tenant.features,
    ))

    try:
        response = await call_next(request)
        return response
    finally:
        _tenant_context.reset(token)
undefined
from fastapi import Request, HTTPException from starlette.middleware.base import BaseHTTPMiddleware
class TenantMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): tenant_id = request.headers.get("x-tenant-id")
    if not tenant_id:
        # Try subdomain
        host = request.headers.get("host", "")
        subdomain = host.split(".")[0]
        if subdomain not in ["www", "app", "api"]:
            tenant_id = subdomain

    if not tenant_id:
        raise HTTPException(400, "Tenant not specified")

    tenant = await db.tenants.find_unique(where={"id": tenant_id})
    if not tenant:
        raise HTTPException(404, "Tenant not found")

    token = set_tenant(TenantContext(
        tenant_id=tenant.id,
        tenant_slug=tenant.slug,
        plan=tenant.plan,
        features=tenant.features,
    ))

    try:
        response = await call_next(request)
        return response
    finally:
        _tenant_context.reset(token)
undefined

Best Practices

最佳实践

  1. Always filter by tenant_id - Never trust client-provided IDs alone
  2. Use middleware - Centralize tenant resolution
  3. Index tenant_id - Every tenant-scoped table needs this index
  4. Consider RLS - Extra protection layer in PostgreSQL
  5. Cache tenant config - Avoid repeated lookups
  1. 始终按tenant_id过滤 - 永远不要只信任客户端提供的ID
  2. 使用中间件 - 集中处理租户解析
  3. 为tenant_id创建索引 - 每个租户范围的表都需要这个索引
  4. 考虑使用RLS - PostgreSQL中的额外保护层
  5. 缓存租户配置 - 避免重复查询

Common Mistakes

常见错误

  • Forgetting tenant filter on queries (data leak!)
  • Not validating user's tenant access
  • Hardcoding tenant-specific logic
  • No index on tenant_id columns
  • Allowing cross-tenant references
  • 查询时忘记添加租户过滤(数据泄露风险!)
  • 未验证用户的租户访问权限
  • 硬编码租户特定逻辑
  • 未为tenant_id列创建索引
  • 允许跨租户引用