内容提要
本文介绍如何在Node.js和PostgreSQL中构建多租户SaaS API,核心是确保租户数据隔离。通过JWT携带租户ID,在中间件中强制认证、角色权限和按租户限流,仓库层查询始终过滤租户ID,并设置不可修改的审计日志。文章强调隔离测试的重要性,以防数据泄露,并指出行级隔离比按库或按模式隔离更易扩展。
延伸解读
隔离失败为何难以察觉
文章指出,缺少租户过滤的查询不会报错,只会静默返回错误数据,日志中也不会留下任何标记。这种问题可能在生产环境运行数周才被客户或合规审计发现。因此,将隔离测试纳入CI,在每次拉取请求时自动运行,是防止数据泄露的关键防线。
行级隔离的扩展性权衡
文章对比了三种多租户隔离方案:共享数据库加行级隔离、按模式隔离和按数据库隔离。行级隔离在扩展性上优于其他方案,且运维成本更低。虽然大型企业可能因监管压力迁移到其他方案,但对大多数团队而言,行级隔离足以支撑长期发展,无需过早引入复杂架构。
审计日志的数据库级保护
审计日志表通过REVOKE UPDATE和DELETE权限,确保应用无法修改或删除日志记录。这种数据库层面的强制约束比应用层逻辑更可靠,即使代码出现bug尝试更新审计行,数据库也会直接拒绝。同时,日志中记录操作时的用户角色,避免因角色变更而丢失权限上下文。
按租户限流优于按IP限流
IP限流在SaaS场景中可能失效,因为企业客户常通过单一NAT网关共享IP,导致一个租户的流量影响其他租户。文章建议以JWT中的租户ID作为限流键,并可按套餐设置不同限额,这样既能公平分配资源,又能避免误伤。
Q&A
如何在Node.js中实现多租户SaaS API的租户数据隔离?
在Node.js中实现多租户SaaS API的租户数据隔离,核心是使用共享数据库和行级隔离。每个表都包含tenant_id列,每个查询都强制过滤tenant_id。tenant_id必须从经过验证的JWT中获取,而不是从请求体或URL中获取。在仓库层,每个函数都接受tenantId作为必需参数,并确保查询包含WHERE tenant_id = $N条件。
为什么tenant_id必须来自JWT而不是请求参数?
因为用户控制请求体和URL中的内容,他们可以随意修改这些值,从而访问其他租户的数据。而JWT是由服务器签发的,包含的tenant_id是可信的,用户无法篡改。因此,从JWT中提取tenant_id可以确保数据隔离的安全性。
在RBAC中,如何定义角色层级并实现权限检查?
RBAC中定义了四个角色:SuperAdmin(最高)、TenantAdmin、Member、Viewer(最低)。每个角色对应一个数值级别,通过requireRole中间件检查用户角色是否满足所需的最低级别。如果用户级别低于要求,则返回403错误。
如何确保审计日志不可篡改?
审计日志的不可篡改性通过数据库层面的权限控制实现。在创建审计日志表后,使用REVOKE语句撤销应用用户对audit_logs表的UPDATE和DELETE权限。这样即使应用代码有bug尝试修改审计日志,数据库也会拒绝执行。
为什么按租户限流比按IP限流更适合SaaS?
因为企业客户可能通过单个NAT网关路由大量用户,共享一个IP地址。如果按IP限流,一个租户的请求可能会耗尽限额,导致其他租户被误限流。按租户限流使用JWT中的tenant_id作为键,可以公平地分配每个租户的请求配额。
如何测试租户隔离是否有效?
编写专门的租户隔离测试文件,创建两个租户A和B,并确保租户A的用户无法访问租户B的资源。测试包括:租户A用户获取租户B项目应返回404,列表不应包含租户B的项目,以及Viewer角色不能删除项目。将这些测试集成到CI中,每次拉取请求时自动运行。
为什么在获取其他租户的资源时返回404而不是403?
返回404而不是403是为了避免泄露资源的存在。如果返回403,调用者会知道资源存在但无权访问,这提供了额外信息。返回404则让调用者无法区分资源不存在和无权访问,更安全。
审计日志中为什么要记录用户角色?
因为用户角色可能会变化,例如被降级或权限被撤销。如果只记录用户ID,就无法知道操作发生时用户拥有的权限级别。记录操作时的角色可以保留上下文,便于审计和合规审查。