Go Wind UBA 拆解系列 - 多租户与安全:两套隔离机制的边界

📅 2026/7/3 2:10:59
Go Wind UBA 拆解系列 - 多租户与安全:两套隔离机制的边界
Go Wind UBA 拆解系列 - 多租户与安全两套隔离机制的边界本文回答一个问题一个 SaaS 级 UBA 平台怎么保证租户 A 绝对看不到租户 B 的数据答案藏在两层完全不同的机制里——而它们的边界差异恰恰是最值得讲的部分。一、两套机制一个真相先说结论GoWind UBA 的多租户隔离不是一套统一机制而是两套独立的、设计哲学迥异的机制数据层隔离机制哲学关系层PostgreSQL entTenantPrivacy行级隐私策略fail-closed缺上下文直接拒绝OLAP 层ClickHouse/Doris手工拼tenant_id ?条件opt-in请求带 appId 才裁剪这个差异不是 bug是两种数据访问范式决定的——但它是整个平台安全模型里最需要被理解、也最容易被忽略的部分。本文把它讲透。二、关系层ent TenantPrivacy自动行级隔离所有走 ent ORM 的实体应用、用户、角色、权限、字典、菜单、事件 Schema、风险规则等配置数据都享受自动租户隔离。机制三件套mixin 声明字段、privacy 策略注入谓词、viewer context 提供租户身份。2.1 Mixin声明 tenant_id 字段 绑定策略每个 ent schema 通过mixin.TenantID[uint32]{}声明租户字段来自github.com/tx7do/go-crud/entgogo代码解读复制代码// backend/app/core/service/internal/data/ent/schema/uba_application.go func (Application) Mixin() []ent.Mixin { return []ent.Mixin{ mixin.AutoIncrementId{}, mixin.TimeAt{}, mixin.OperatorID{}, mixin.TenantID[uint32]{}, // ← 这一行干了三件事 } }这一个 mixin 干了三件事库源码mixin/tenant_id.gogo代码解读复制代码func (TenantID[IDT]) Fields() []ent.Field { return []ent.Field{ field.Uint32(tenant_id). Comment(租户ID). Immutable(). // ① 写入后不可跨租户迁移 Default(0). Nillable().Optional(), } } func (TenantID[IDT]) Policy() ent.Policy { return rule.TenantPrivacy[IDT]{} // ② 绑定隐私策略 }Immutable()是个细节一行数据的tenant_id一旦写入就不能改防止数据搬家式的越权。2.2 TenantPrivacy查询和写入的强制拦截rule.TenantPrivacy库源码rule/tenant.go实现了两个钩子。查询拦截EvalQuery——自动注入 tenant_id 谓词go代码解读复制代码func (f TenantPrivacy[T]) EvalQuery(ctx context.Context, query ent.Query) error { vc, exist : viewer.FromContext(ctx) if !exist { return fmt.Errorf(security: missing ViewerContext in context) // ① fail-closed } if vc.IsPlatformContext() || vc.IsSystemContext() { return nil // ② 平台/系统上下文看所有租户 } tid : vc.TenantID() return f.injectTenantWhere(query, T(tid)) // ③ 注入 WHERE tenant_id ? }注入谓词的代码很直接go代码解读复制代码fn : func(s *sql.Selector) { s.Where(sql.EQ(s.C(tenant_id), tenantID)) }两个关键安全属性fail-closed——!exist时直接return error不是跳过。这意味着如果你忘了往 context 注入 viewer查询会直接报错拒绝而不是忘了过滤、返回所有租户数据。这是默认安全的姿态。平台/系统上下文豁免——IsPlatformContext()tid0和IsSystemContext()看所有租户给 SaaS 运营后台和后台 job 留了口子。写入拦截EvalMutation——强制覆盖 tenant_idgo代码解读复制代码func (f TenantPrivacy[T]) EvalMutation(ctx context.Context, m ent.Mutation) error { vc, exist : viewer.FromContext(ctx) if !exist { return fmt.Errorf(missing ViewerContext in context) // 同样 fail-closed } if !m.Op().Is(ent.OpCreate) { return nil } tid : vc.TenantID() if vc.IsPlatformContext() { // 平台管理员尊重显式 .SetTenantID(...) if _, set : m.Field(tenant_id); set { return nil } return nil } // 普通用户强制覆盖防止越权写到别的租户 if s, ok : m.(interface{ SetTenantID(T) }); ok { s.SetTenantID(T(tid)) return nil } // ...reflect 兜底 }普通用户创建数据时tenant_id被强制覆盖成自己的——客户端即便传了tenantId别人的也会被无视。这是防越权写入的关键。平台管理员才能显式指定租户运营后台创建租户数据时需要。同一模块还定义了OwnerOnlyRule只能改自己创建的、PermissionRule基于 org-unit 数据范围、SoftDeleteRule都遵循viewer context gating模式。2.3 Viewer 从哪来JWTUserViewer在 auth 中间件里从 JWT 解析构建backend/pkg/middleware/auth/auth.gogo代码解读复制代码if op.injectEnt { userViewer : appViewer.NewUserViewer( uint64(tokenPayload.GetUserId()), uint64(tokenPayload.GetTenantId()), // 租户身份来自 JWT uint64(tokenPayload.GetOrgUnitId()), traceID, tokenPayload.GetDataScope(), ) ctx viewer.WithContext(ctx, userViewer) }viewerpkg/entgo/viewer/user_viewer.go暴露TenantID()/IsPlatformContext()(tid0) /IsTenantContext()(tid0) /DataScope()。还有一个SystemViewerIsSystemContext()truetid0给后台 job 绕过租户过滤用。还有第二个等价注入点pkg/middleware/ent/ent.go的ent.Server()从 operator-metadata 重建 viewer给那些走 metadata 而非 auth 中间件的内部调用用注册在grpc_server.gogo代码解读复制代码ms append(ms, ent.Server())关系层的隔离是基础设施级的——开发者写client.User.Query().All(ctx)时不用记得加WHERE tenant_idprivacy 策略自动加忘了注入 viewer 会直接报错而非泄漏。这是 ent privacy 扩展的威力。三、OLAP 层手工 SQLopt-in 裁剪OLAP repo完全绕过 ent直接用go-crudclient 跟引擎对话。租户隔离在这里是手工的、查询级的而且来源是请求里的 appId不是 viewer context。3.1 逐查询拼接每个分析方法都重复这个模式doris/analytics_repo.go24 个方法都有go代码解读复制代码if v : req.GetAppId(); v ! 0 { where append(where, tenant_id ?) args append(args, v) }或者用字符串模板go代码解读复制代码tenantCond : if v : req.GetAppId(); v ! 0 { tenantCond tenant_id ? AND }租户条件始终用?绑定参数不字符串拼接这一点是对的。但触发条件是if v ! 0——如果 appId 没传0查询就不带租户条件跨所有租户扫描。3.2 ⚠️ 这是诚实的非对称这是整个平台安全模型里最需要被理解的一点。跟关系层对比维度关系层entOLAP 层隔离来源viewer context来自 JWT请求里的 appId缺失身份时fail-closed报错拒绝opt-in不裁剪跨租户扫谁负责框架自动privacy 策略开发者手工每个查询记得加OLAP 层是 opt-in 的。这不是漏洞因为对外接口都从 Admin BFF 进BFF 会从 JWT 拿到 appId 填进请求但它意味着如果有人新增一个 OLAP 查询入口忘了从 appId 注入 tenant 条件这个查询就会跨租户泄漏。跟关系层忘了注入 viewer 直接报错的默认安全姿态不对称。events_fact的 DDL 注释也明说这个预期ClickHouse 版sql代码解读复制代码tenant_id UInt32 COMMENT 租户 IDSaaS 多租户隔离所有查询必须带此条件,所有查询必须带此条件——这是靠约定不是靠强制。做 SaaS 二次开发时这是一个要盯紧的点任何新的 OLAP 查询第一步就是确认req.GetAppId()被正确解析并拼进tenant_id ?。为什么会这样设计因为 OLAP 层走原生 SQL没有 ent 那样的查询构建器拦截层要自动注入谓词得自己造一套 SQL 重写机制复杂度高。项目选择了靠开发者自律 DDL 注释提醒的轻量方案。这是一个诚实的工程取舍——能 work但不是默认安全。3.3 物理布局补强即便 OLAP 层的逻辑隔离靠手工物理布局还是把租户聚集做到了极致让带 tenant_id 条件的查询飞快ClickHouseORDER BY (tenant_id, event_category, event_date, event_name, event_ts)——tenant_id是首列租户数据在排序 part 里物理连续查询能跳过无关 granule。DorisDISTRIBUTED BY HASH(event_id, tenant_id) BUCKETS 16——tenant_id参与 hash一个租户的数据落在确定的 bucket 子集。ClickHouse 的id_mapping表甚至直接按租户分区PARTITION BY tenant_id -- 按租户分区支持多租户隔离。所以逻辑层手工 物理层聚集是配套的——前提是逻辑层记得带条件。四、采集层appId 权威覆盖除了上面两层采集端还有第三道闸第 3 篇 详述。这是租户安全的第一道、也是最关键的一道go代码解读复制代码// backend/app/collector/service/.../report_service.go // 用应用所属的权威 tenant_id 覆盖每个事件杜绝客户端伪造跨租户上报。 for _, event : range validEvents { event.TenantId app.TenantID }validateEvent故意不校验 tenant_id注释解释它反正会被服务端覆盖客户端没必要也无法有效上报。appId → tenantId的映射在AppAuthenticator.Authenticate里完成appId 解析到应用记录 → 返回应用所属租户。三道闸的分工采集端CollectorappId 鉴权 → 权威覆盖 tenantId。防上报伪造。关系层entJWT viewer → TenantPrivacy 自动过滤。防配置数据越权读写。OLAP 层CoreappId → 手工拼tenant_id ?。防分析数据跨租户查询。第 1、2 道是默认安全的第 3 道靠约定。三者组合整体安全模型成立。五、鉴权与权限JWT Casbin除了租户隔离平台还有完整的认证授权体系。5.1 双轨认证场景机制凭证位置管理后台登录JWTHS256Authorization HeaderSDK 上报appId appSecret请求 body为了 sendBeacon两套机制服务两类客户端管理后台是人用 JWTSDK 是程序用应用凭证。权限粒度也不同——后台到按钮级SDK 只到这个应用能不能上报。5.2 权限引擎Casbin / OPA权限走策略引擎Casbin 或 OPA分三级菜单权限控制能看到哪些菜单接口权限控制能调哪些 API数据权限DataScope控制能看到哪些 org-unit 的数据PermissionRule实现Casbin 的 RBAC with domains 模型天然适配多租户(sub, dom, obj, act)四元组里的dom就是租户。这让同一个角色名在不同租户里有不同权限成为可能。5.3 Collector 鉴权的加固细节第 3 篇 讲过的几个点在这里汇总它们都属于安全加固Redis 只存 secret 哈希——脱库不泄密。constant-time 比较——防时序攻击。负缓存——防缓存穿透爆破。可用性≠鉴权失败——网络抖动不误报为密码错。状态检查——禁用应用拒绝上报。这些每一项都防一类真实攻击组合起来才是生产级。