跳到正文
← 返回博客
ESSAY / 思考

多租户隔离,不是给每张表加一个 tenantCode

从登录态、账号域、模块开通、RBAC 到数据访问:多租户边界必须成为服务端可验证的执行契约。

多租户系统很容易制造一种安全错觉:只要每张业务表都有 tenantCode,查询时再加一个过滤条件,租户隔离就已经完成。

这个字段当然重要,但它只是数据模型中的一个标记。真正的隔离要回答的是:租户身份从哪里来、谁有权使用它、模块是否已经开通、角色是否允许当前动作,以及数据访问层是否可能绕过这些判断。

如果这些问题没有统一答案,tenantCode 反而会变成一个可以被请求方自由填写的参数。

前端不是安全边界

前端可以根据当前租户隐藏菜单、按钮和数据行,但这些都属于体验层约束。任何能够直接构造请求的调用方,都可能绕过页面上的筛选。

因此我不会把请求体中的租户标识直接传入数据库查询。更可靠的链路是:

登录凭证
  → 服务端解析账号域
    → 确认平台 / 租户身份
      → 校验模块开通与权限
        → 生成可信执行上下文
          → 数据访问

查询真正使用的 tenantCode 应来自服务端确认过的登录态,而不是客户端声明。客户端可以表达“我要访问哪个资源”,但不能决定“我属于哪个租户”。

身份、权限与数据要在同一条链上

企业平台通常不只有一种身份。平台管理员、租户管理员、普通成员和智能体可能拥有不同的作用域;同一个租户也不一定开通所有业务模块。

在实际架构中,我把判断拆成几个连续层级:

  1. JWT 是否有效,账号属于平台域还是租户域;
  2. 当前租户是否存在且状态有效;
  3. 请求对应的模块是否已经开通;
  4. RBAC 是否允许当前角色执行这个动作;
  5. Agent Scope 是否覆盖目标资源;
  6. 数据查询是否强制绑定可信租户上下文。

这些步骤不是互相替代的。通过角色校验不代表模块一定开通,通过模块校验也不代表某个智能体可以访问全部数据。

用事件契约约束业务入口

多租户边界如果散落在每个页面和接口里,长期一定会出现不同实现。新增模块时,开发者或 AI 可能复制一段相似代码,却遗漏账号域、模块开通或 Agent Scope 中的某一层。

事件驱动架构可以把业务入口收敛为明确链路:View 只发送 Action,Workflow 决定事件去向,Logic 接收 State 后执行权限和业务规则。Server Logic 再从可信上下文进入数据层。

这不意味着“使用了 Workflow 就自动安全”,而是让每个业务动作都有可追踪入口:

  • 谁发起了事件;
  • 哪个 Workflow 负责转发;
  • 哪个 Logic 执行权限判断;
  • 哪个数据控制器真正读写;
  • 结果通过什么事件返回。

当入口可以被图谱、类型和测试定位时,遗漏才更容易被发现。

共享表比独立表更需要契约

多租户平台演进过程中,经常出现多个模块共同使用素材、文件、模型或日志表。真正危险的不一定是没有 tenantCode,而是不同模块各自理解同一字段:有人把资源 ID 当字符串,有人当数字;有人执行软删除过滤,有人直接读取;有人校验租户,有人相信上游已经校验。

我处理这类问题时,会先梳理调用拓扑,再把共享表的模型、读写入口和租户规则收敛到单一契约。一次治理中,16 个前端模块的后端事件关系被重新梳理,约 30 处分散硬编码调用被收口为类型安全注册表,4 个共享素材表也逐步统一到契约化入口。

重点不是数字本身,而是消除“每个调用方都有一份自己的真相”。

隔离需要负向测试

只测试“当前租户可以读到自己的数据”远远不够。隔离验证必须包含明确的负向路径:

  • 修改请求体中的租户标识,服务端是否仍坚持使用登录态租户;
  • 使用未开通模块的租户调用接口,是否在业务执行前拒绝;
  • 普通角色调用管理动作,是否得到稳定且可审计的失败结果;
  • 共享资源 ID 属于其他租户时,是否不会泄露资源是否存在;
  • 新增接口是否沿用了统一上下文和数据入口。

类型检查能够发现字段和事件漂移,单元测试可以覆盖纯权限规则,真实数据库集成检查则负责证明查询条件确实落到了数据层。三个层级缺一不可。

边界的价值是让错误难以发生

多租户隔离不是一个字段,也不是某个中间件的名字。它是一条从身份到权限、从事件到数据、从正向行为到负向验证的连续证据链。

好的架构不会依赖每位开发者每次都记住全部规则。它会把可信租户上下文、统一业务入口、共享数据契约和隔离测试组合起来,让越权路径难以被写出,也让新增模块能够沿着同一套边界自然生长。