GoWind Admin|风行 --- 开箱即用的企业级全栈中后台框架:多租户与行级数据隔离
SaaS 化的第一道坎就是数据隔离:租户 A 的运营人员绝不能看到租户 B 的一行数据------这不是"前端藏好按钮"能解决的事,而是要从 HTTP 入口一路压实到 SQL 的 WHERE 子句。多数自研方案的做法是在每个查询里手写
tenant_id条件,写漏一处就是一次数据泄露事故。风行(GoWind Admin)把租户隔离做成了编译进数据访问层的默认行为:查询自动注入租户谓词、跨租户变更必扑空、伪造租户 ID 直接被覆盖,配合 HTTP 层的 Api 表闸门形成纵深防御。本文拆解这套隔离体系的三道防线与覆盖边界。
一、租户模型与上下文链路
每个带租户维度的表都有 tenant_id 列(ent mixin 装配,业务开发者声明一次即可)。请求进入后端后,认证中间件从已验签的 JWT 构建 ViewerContext(租户 ID + 平台/系统上下文标志),作为后续所有隔离判定的唯一依据:
- 平台管理员(tenant_id=0):跨租户可见,用于运营管理;
- 租户用户:所有数据访问自动被裁剪到本租户。

租户与套餐的绑定由平台管理员在「租户管理」页维护;租户侧用户看到的数据范围由各层自动裁剪,业务代码无需(也无法)手动传递租户 ID。
二、第一道防线:HTTP 层 Api 表闸门
每个租户请求在进入业务逻辑之前,按(路由模板,method)查 Api 表(接口注册表,含模块归属):
- 命中且该租户套餐允许该模块 → 放行到业务;
- 未命中(表里没这行)或套餐不允许 → fail-closed 403。
注意"fail-closed"这个取向:接口没登记进 Api 表的后果是拒绝服务,而不是意外放行。对多租户系统来说,这是唯一正确的默认------新增端点忘记登记,损失的是功能可用性,而不是数据安全。
两个运维要点:
- Api 表仅在空表时由启动期从内嵌 OpenAPI 文档自动同步;已部署实例新增端点后,需在管理页「接口管理 → 接口同步」手动触发全量重建;
- "平台管理员测通了"≠"租户测通了"------平台上下文不走这道闸门,验收必须拿租户账号测。
三、第二道防线:数据层读写全形态隔离
这是整个体系最核心的部分。风行没有采用"业务代码自觉带租户条件"的约定式隔离,而是把隔离编译进 ent 生成的数据访问代码:
查询自动注入谓词 。所有租户表的查询自动追加 tenant_id = <viewer tid>,业务代码里写的 client.User.Query().All(ctx) 天然只返回本租户用户。
变更全形态覆盖。Update/UpdateOne/Delete/DeleteOne 的 WHERE 恒含租户谓词------跨租户变更 0 行命中、单键操作返回 NotFound,攻击者拿着别的租户的资源 ID 来改来删,得到的只是"不存在"。
Create 强制覆盖 tenant_id 。请求体里伪造 tenant_id 字段?Create 时强制以 ViewerContext 的租户 ID 覆盖写入,防伪造;显式改 tenant_id 值仅放行"同租户冗余设置"。
go
// 接新表 = 声明两行,隔离自动生效
func (User) Mixin() []ent.Mixin {
return []ent.Mixin{
mixin.TenantID[uint32]{}, // 库层:读写全形态隔离
}
}
func (User) Policy() ent.Policy {
return &TenantMutationGuardPolicy{} // 冗余层:纵深防御
}
纵深防御 :在库层隔离之外,还有一层仓内冗余守卫(TenantMutationGuardPolicy)挂着相同谓词------两层叠加,任一层失效另一层兜底。现有 33 张租户表无一例外都挂了这套组合,且有单测钉住守卫行为。
组合策略 :TenantAndDataScopePolicy 把租户变更防护与数据范围查询过滤链在一起------任一子策略拒绝即整体拒绝。
四、行级第二维:数据范围(组织维度)
同一行隔离层里还有组织维度的过滤:角色的数据范围五档------全部 / 本部门 / 本部门及以下 / 自定义 / 仅本人 。由令牌 claim 承载聚合结果,查询时注入 created_by = uid 或 org_unit_id IN (targets) 谓词。
租户隔离回答"是哪个公司的数据",数据范围回答"是公司里谁的数据"------两个正交维度叠加,构成完整的行级权限模型。兜底取向同样是 fail-closed:聚合出空集整体拒绝、超限退化收敛,宁可不可用也不越权。
五、覆盖边界:诚实声明
行级隔离覆盖的是 ent 数据层 。以下路径不会自动注入租户谓词,业务侧必须自己携带并校验租户维度:
- 应用层自有缓存(Redis key 构造);
- 对象存储路径(OSS / MinIO 对象 key);
- 异步任务载荷(asynq 任务体);
- 任何绕过 ent 的直连 SQL / GORM 路径。
这是所有 ORM 级隔离方案的共同边界------接入新存储或新队列时,先想清楚租户维度怎么进 key / 路径 / 载荷,再写代码。风行把这条例成了接入规范,而不是留给开发者踩坑。
六、与套餐计费的联动
隔离体系还与 SaaS 计费联动:租户绑定的套餐决定可用模块白名单 (Api 表闸门的第三段)与到期处置------READONLY 即时只读降级、BLOCK_LOGIN / FREEZE 经系统扫描任务把租户状态置为过期/冻结并吊销全部用户令牌。租户报 403 或只读,先查套餐模块白名单与到期时间,再查接口同步------这条排障决策树写在文档里。
套餐与计费的完整机制见本系列下一篇文章。
结语
多租户隔离的正确姿势,不是"提醒每个开发者记得带租户条件",而是让忘记带条件这件事不可能发生。风行把隔离编译进数据访问层、把闸门立在 HTTP 入口、把兜底做成 fail-closed,三层防线加上诚实的边界声明,让 SaaS 化的数据安全从"代码纪律"变成"架构保证"。
项目地址 :github.com/tx7do/go-wi... / gitee.com/tx7do/go-wi...
在线演示 :demo.admin.gowind.cloud(前端)/ api.demo.admin.gowind.cloud/docs/(后端 Swagger)