关于 saas-multi-tenant
为构建多租户 SaaS 的开发者提供架构指导,重点涵盖租户隔离、数据库行级安全和请求上下文。内容还涉及迁移、跨租户管理端点及后台任务的租户边界。
主要功能
- 01比较多租户隔离模型
- 02设计 tenant_id 与索引
- 03配置 PostgreSQL 行级安全
- 04构建租户感知中间件
- 05规划迁移与后台任务隔离
适用场景
- 01为共享数据库的 SaaS 添加租户隔离
- 02使用 PostgreSQL RLS 防止跨租户读取
- 03实现安全的跨租户管理端点
- 04为租户数据导出与后台任务设定范围
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ sickn33/ agentic-awesome-skills --skill saas-multi-tenant安装后在 Claude Code 中输入/saas-multi-tenant确认已加载。
详细介绍
SaaS 多租户架构
何时使用此技能
- 用户正在构建多个客户共用同一数据库的 SaaS 应用
- 用户询问租户隔离、行级安全或如何防止数据泄露
- 用户需要将每个数据库查询限定到特定租户,而无需手动添加 WHERE 子句
- 用户询问共享架构、每租户一个架构或每租户一个数据库之间的权衡
- 用户正在实现需要跨租户访问数据的管理端点
- 用户需要为现有单租户应用添加
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:显式事务边界
不要在 HTTP 响应的 finish 事件上提交:处理程序可能失败、客户端可能断开连接,或者响应可能已经报告成功,但 COMMIT 随后失败。使用可等待的事务包装器,并且只在提交后发送响应:
async function withTenantTransaction(pool, tenantId, work) {
const client = await pool.connect();
let destroy = false;
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [tenantId]);
const result = await work(client); // Every tenant query uses this client.
await client.query("COMMIT");
return result;
} catch (error) {
try { await client.query("ROLLBACK"); }
catch { destroy = true; }
throw error;
} finally {
client.release(destroy); // Destroy on rollback failure; release exactly once.
}
}
使用前,请添加请求超时/取消机制和针对目标的集成测试。调用前,务必在服务器端验证用户当前是否属于该租户;切勿信任未经验证的租户声明或路由标头。
示例 3:历史 Prisma 中间件示意
这个不完整的旧版 $use 示例并非隔离层。它遗漏了 findUnique、upsert、嵌套操作、关系加载和原始查询,也不会绑定事务本地的 RLS 上下文。当前 Prisma 版本可能需要改用扩展。不要将它部署为自动防护措施;应枚举每种操作,禁止客户端更改租户归属,并使用经过测试的策略,以非所有者且非 BYPASSRLS 的数据库角色执行操作。
import { PrismaClient } from "@prisma/client";
// Tables that do NOT have tenant_id (global tables)
const GLOBAL_TABLES = new Set(["Plan", "FeatureFlag", "SystemConfig"]);
function createTenantPrisma(tenantId: string): PrismaClient {
const prisma = new PrismaClient();
prisma.$use(async (params, next) => {
if (GLOBAL_TABLES.has(params.model ?? "")) return next(params);
// Initialize args.where — Prisma passes undefined args for calls like findMany()
params.args = params.args ?? {};
params.args.where = params.args.where ?? {};
// Inject tenant filter on reads (skip findUnique — it only accepts unique-field selectors)
if (["findMany", "findFirst", "count", "aggregate"].includes(params.action)) {
params.args.where = { ...params.args.where, tenantId };
}
// Inject tenant_id on creates
if (["create", "createMany"].includes(params.action)) {
params.args.data = params.args.data ?? {};
if (params.action === "createMany") {
params.args.data = params.args.data.map((d: any) => ({ ...d, tenantId }));
} else {
params.args.data = { ...params.args.data, tenantId };
}
}
// Scope updates and deletes
if (["update", "updateMany", "delete", "deleteMany"].includes(params.action)) {
params.args.where = { ...params.args.where, tenantId };
}
return next(params);
});
return prisma;
}
切勿这样做
-
切勿在查询租户范围内的表时省略
tenant_id筛选条件。 即使 ORM 中间件会处理,原始 SQL 查询也会完全绕过中间件。每条原始查询都必须包含WHERE tenant_id = $1或依赖 RLS。一条未限定范围的SELECT * FROM invoices就会泄露所有客户的账单数据。 -
切勿只在应用程序会话中存储
tenant_id,而不在数据库层强制执行。 应用层筛选只是建议,RLS 才是强制措施。如果中间件中的错误跳过了租户筛选,只有 RLS 能防止数据泄露。两层都要启用。 -
切勿为租户范围内的资源使用自增整数 ID。 顺序 ID(
invoice #1042)会让攻击者通过递增 ID 枚举其他租户的资源。所有租户范围内的主键都应使用 UUID。整数 ID 仅用于内部表。 -
切勿让租户用户访问管理员聚合端点。 像
GET /admin/metrics这样跨所有租户查询的路由,绝不能允许使用普通租户 JWT 的用户访问。跨租户路由应使用单独的身份验证机制(API 密钥、由不同签发方签发的管理员角色声明)。 -
使用权限最小化的独立迁移角色。 RLS 控制行访问,不控制 DDL 可见性;ALTER TABLE 不会悄悄地只修改某个租户的数据。应针对每个目标租户测试数据迁移,并且只在执行所需操作时授予提升后的权限,切勿默认使用超级用户权限。
-
只有在能可靠清理事务时才共享连接池。 如果在事务中使用
SET LOCAL app.current_tenant_id,该设置仅在事务范围内生效。但如果之前请求的事务没有正确提交或回滚,连接归还连接池时就会带有过期的租户上下文。归还连接前应提交/回滚;如果回滚失败,则销毁该连接。SET LOCAL 会在事务结束时失效;RESET 无法修复仍处于打开或失败状态的事务。
边界情况
-
租户删除与数据保留。 租户取消订阅时,不能只执行
DELETE FROM tenants WHERE id = $1。大数据集上的外键级联删除可能超时。应改为软删除租户(设置deleted_at),撤销所有用户会话,然后运行后台任务,在数小时或数天内分批删除租户数据。 -
租户数据导出以满足 GDPR/合规要求。 租户请求导出全部数据时,需要查询每个按租户划分的表中对应
tenant_id的数据并打包。建立所有按租户划分的表的注册清单(解析迁移文件或维护清单),确保导出任务不会漏掉导出功能开发后新增的表。 -
租户间共享资源。 某些功能需要共享状态,例如市场中租户 A 的产品对租户 B 的用户可见。这些表需要采用不同的 RLS 策略:读取权限公开(不按租户筛选),但写入权限仍限定于资源所属租户。应使用
owner_tenant_id而非tenant_id来表示这类数据。 -
感知租户的后台任务。 cron 任务或队列工作进程处理任务时,没有 HTTP 请求可供提取
tenant_id。任务负载必须包含tenant_id,且工作进程必须在处理前设置数据库会话变量。切勿在没有租户上下文的情况下运行后台任务,否则这些任务要么会被 RLS 拦截,要么会完全绕过 RLS。 -
按租户使用独立 schema 时的连接池耗尽。 如果每个租户使用一个 PostgreSQL schema,且每个 schema 都需要自己的连接池,那么 500 个租户就意味着 500 个连接池,很快就会耗尽
max_connections。应使用 PgBouncer 等连接池代理并启用事务模式,或者在遇到瓶颈前切换到共享 schema。
最佳实践
-
创建
tenants表作为唯一事实来源。 每个表中的每个tenant_id外键都指向tenants.id。添加name、slug(用于子域名路由)、plan_id、created_at和deleted_at列。此表是整个数据模型的根基。 -
在每个复合索引中将
tenant_id作为第一列。 PostgreSQL 对复合索引使用最左前缀匹配。(tenant_id, created_at)索引既适用于“租户 X 的所有条目”,也适用于“按日期排序的租户 X 条目”。(created_at, tenant_id)索引则只能帮助查询所有租户的日期范围。 -
使用子域名或路径前缀进行租户路由。
acme.yourapp.com或yourapp.com/org/acme都可行。在边缘层(中间件或反向代理)将子域名或路径映射为tenant_id。此查找会在每个请求中执行,因此应缓存(使用 Redis,或设置 60s TTL 的内存缓存)。 -
明确区分按租户划分的表和全局表。 维护一份清单(代码常量或数据库表),列出哪些表是全局表(无
tenant_id),哪些表按租户划分。在 ORM 中间件、迁移检查器和数据导出任务中使用此清单。如果某个表不在任一清单中,CI 检查应失败。 -
在种子数据中至少加入 3 个租户进行测试。 开发环境中只有一个租户会掩盖所有多租户问题。两个租户会掩盖这类问题:第一个租户的数据泄漏到第二个租户,但反过来不会。三个租户则能发现只有在存在多个同级租户时才会出现的排序和筛选问题。
-
按租户进行速率限制和配额控制,而非全局控制。 全局速率限制为每分钟 1000 个请求,意味着一个请求量过大的租户就能耗尽所有人的配额。应按租户实施速率限制,使用类似
ratelimit:{tenant_id}:{endpoint}的 Redis 键模式,并采用滑动窗口计数器。
局限性
- 仅当任务明确符合上述范围时,才使用此技能。
- 不要将输出视为环境特定验证、测试或专家审核的替代品。
- 如果缺少必要的输入、权限、安全边界或成功标准,请停止并请求澄清。