多租户系统很容易制造一种安全错觉:只要每张业务表都有 tenantCode,查询时再加一个过滤条件,租户隔离就已经完成。
这个字段当然重要,但它只是数据模型中的一个标记。真正的隔离要回答的是:租户身份从哪里来、谁有权使用它、模块是否已经开通、角色是否允许当前动作,以及数据访问层是否可能绕过这些判断。
如果这些问题没有统一答案,tenantCode 反而会变成一个可以被请求方自由填写的参数。
前端不是安全边界
前端可以根据当前租户隐藏菜单、按钮和数据行,但这些都属于体验层约束。任何能够直接构造请求的调用方,都可能绕过页面上的筛选。
因此我不会把请求体中的租户标识直接传入数据库查询。更可靠的链路是:
登录凭证
→ 服务端解析账号域
→ 确认平台 / 租户身份
→ 校验模块开通与权限
→ 生成可信执行上下文
→ 数据访问
查询真正使用的 tenantCode 应来自服务端确认过的登录态,而不是客户端声明。客户端可以表达“我要访问哪个资源”,但不能决定“我属于哪个租户”。
身份、权限与数据要在同一条链上
企业平台通常不只有一种身份。平台管理员、租户管理员、普通成员和智能体可能拥有不同的作用域;同一个租户也不一定开通所有业务模块。
在实际架构中,我把判断拆成几个连续层级:
- JWT 是否有效,账号属于平台域还是租户域;
- 当前租户是否存在且状态有效;
- 请求对应的模块是否已经开通;
- RBAC 是否允许当前角色执行这个动作;
- Agent Scope 是否覆盖目标资源;
- 数据查询是否强制绑定可信租户上下文。
这些步骤不是互相替代的。通过角色校验不代表模块一定开通,通过模块校验也不代表某个智能体可以访问全部数据。
用事件契约约束业务入口
多租户边界如果散落在每个页面和接口里,长期一定会出现不同实现。新增模块时,开发者或 AI 可能复制一段相似代码,却遗漏账号域、模块开通或 Agent Scope 中的某一层。
事件驱动架构可以把业务入口收敛为明确链路:View 只发送 Action,Workflow 决定事件去向,Logic 接收 State 后执行权限和业务规则。Server Logic 再从可信上下文进入数据层。
这不意味着“使用了 Workflow 就自动安全”,而是让每个业务动作都有可追踪入口:
- 谁发起了事件;
- 哪个 Workflow 负责转发;
- 哪个 Logic 执行权限判断;
- 哪个数据控制器真正读写;
- 结果通过什么事件返回。
当入口可以被图谱、类型和测试定位时,遗漏才更容易被发现。
共享表比独立表更需要契约
多租户平台演进过程中,经常出现多个模块共同使用素材、文件、模型或日志表。真正危险的不一定是没有 tenantCode,而是不同模块各自理解同一字段:有人把资源 ID 当字符串,有人当数字;有人执行软删除过滤,有人直接读取;有人校验租户,有人相信上游已经校验。
我处理这类问题时,会先梳理调用拓扑,再把共享表的模型、读写入口和租户规则收敛到单一契约。一次治理中,16 个前端模块的后端事件关系被重新梳理,约 30 处分散硬编码调用被收口为类型安全注册表,4 个共享素材表也逐步统一到契约化入口。
重点不是数字本身,而是消除“每个调用方都有一份自己的真相”。
隔离需要负向测试
只测试“当前租户可以读到自己的数据”远远不够。隔离验证必须包含明确的负向路径:
- 修改请求体中的租户标识,服务端是否仍坚持使用登录态租户;
- 使用未开通模块的租户调用接口,是否在业务执行前拒绝;
- 普通角色调用管理动作,是否得到稳定且可审计的失败结果;
- 共享资源 ID 属于其他租户时,是否不会泄露资源是否存在;
- 新增接口是否沿用了统一上下文和数据入口。
类型检查能够发现字段和事件漂移,单元测试可以覆盖纯权限规则,真实数据库集成检查则负责证明查询条件确实落到了数据层。三个层级缺一不可。
边界的价值是让错误难以发生
多租户隔离不是一个字段,也不是某个中间件的名字。它是一条从身份到权限、从事件到数据、从正向行为到负向验证的连续证据链。
好的架构不会依赖每位开发者每次都记住全部规则。它会把可信租户上下文、统一业务入口、共享数据契约和隔离测试组合起来,让越权路径难以被写出,也让新增模块能够沿着同一套边界自然生长。