
关于
设计和实现多租户 SaaS 架构,包含行级安全、租户范围查询、共享 Schema 隔离和 PostgreSQL/TypeScript 中安全的跨租户管理模式。
name: saas-multi-tenant description: "设计和实现多租户 SaaS 架构,包括行级安全、租户范围查询、共享模式隔离,以及 PostgreSQL 和 TypeScript 中安全的跨租户管理模式。" risk: safe source: community date_added: "2026-03-28" tags: [multi-tenancy, saas, row-level-security, postgresql, tenant-isolation] tools: [claude, cursor, gemini]
SaaS 多租户架构
何时使用此技能
- 用户正在构建多个客户共享同一数据库的 SaaS 应用
- 用户询问租户隔离、行级安全或数据泄露防护
- 用户需要将每个数据库查询限定到特定租户,而无需手动添加 WHERE 子句
- 用户询问共享模式 vs 每租户模式 vs 每租户数据库的权衡
- 用户正在实现必须跨租户访问数据的管理端点
- 用户需要为现有单租户应用添加
tenant_id列 - 用户询问 PostgreSQL RLS 策略用于租户隔离
- 用户正在 Express、Fastify 或 Next.js API 路由中构建租户感知中间件
不要在以下情况使用此技能:
- 用户正在构建没有共享基础设施的单用户应用
- 用户仅询问认证而不涉及租户范围(请使用认证技能)
- 用户需要通用数据库模式设计而不涉及多租户需求
核心工作流
-
确定租户模型。询问用户关于规模预期和隔离需求。对于大多数 1000 租户以下的 SaaS 应用,在每个表上使用
tenant_id列的共享模式是正确的默认选择。每租户模式增加运维开销(迁移需要运行 N 次)。每租户数据库仅在租户有监管数据驻留要求时才有必要。 -
为每个租户范围的表添加
tenant_id。该列必须为NOT NULL,类型为UUID或TEXT,并包含在每个复合索引中。永远不要让租户范围的表没有此列——缺少tenant_id就是等待发生的数据泄露。 -
设置 PostgreSQL 行级安全(RLS)。在每个租户范围的表上创建策略,通过
current_setting('app.current_tenant_id')过滤行。这作为数据库级别的安全网——即使应用代码忘记了 WHERE 子句,RLS 也会阻止跨租户读取。 -
构建租户感知中间件。在每个请求开始时,从认证会话或 JWT 声明中提取
tenant_id。在事务中使用SET LOCAL app.current_tenant_id = '...'将其设置到数据库连接上。该请求中的每个后续查询都会自动继承租户范围。 -
通过租户限定所有 ORM 查询。如果使用 Prisma,应用全局中间件在每个
findMany、findFirst、update和delete调用中注入where: { tenantId }。如果使用 Drizzle,创建包含租户过滤器的基础查询构建器。永远不要依赖开发者记住手动添加过滤器。 -
处理租户感知迁移。每个新表迁移必须包含
tenant_id作为列。编写 lint 规则或 CI 检查,拒绝任何创建不含tenant_id的表的迁移,除非该表被明确标记为全局表(如plans、feature_flags)。 -
单独构建跨租户管理路由。聚合跨租户数据的管理端点必须使用
SET LOCAL role = 'admin_bypass'或专用数据库角色显式绕过 RLS。这些路由必须受到单独的管理员认证流程保护——永远不要将租户用户会话用于管理员访问。 -
实现租户配置。当新客户注册时,创建其租户记录,填充默认数据(角色、设置、引导状态),并分配创始用户。将此包装在数据库事务中,以确保部分配置永远不会留下孤立记录。
示例
示例 1:用于租户隔离的 PostgreSQL RLS 策略
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
-- Policy: users can only see rows where tenant_id matches the session variable
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Policy for INSERT: new rows must match the current tenant
CREATE POLICY tenant_insert ON projects
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
示例 2:设置每请求租户上下文的 Express 中间件
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function tenantMiddleware(req, res, next) {
const tenantId = req.auth?.tenantId; // extracted from JWT during auth
if (!tenantId) return res.status(403).json({ error: "No tenant context" });
const client = await pool.connect();
try {
await client.query("BEGIN");
// Use set_config — SET LOCAL does not
