当前位置: 首页 > news >正文

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

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

本文回答一个问题:一个 SaaS 级 UBA 平台,怎么保证租户 A 绝对看不到租户 B 的数据?答案藏在两层完全不同的机制里——而它们的边界差异,恰恰是最值得讲的部分。

一、两套机制,一个真相

先说结论:GoWind UBA 的多租户隔离不是一套统一机制,而是两套独立的、设计哲学迥异的机制

数据层隔离机制哲学
关系层(PostgreSQL + ent)TenantPrivacy行级隐私策略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/entgo):

go

代码解读

复制代码

// 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.go):

go

代码解读

复制代码

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)) }

两个关键安全属性:

  1. fail-closed——!exist时直接return error不是跳过。这意味着如果你忘了往 context 注入 viewer,查询会直接报错拒绝,而不是"忘了过滤、返回所有租户数据"。这是默认安全的姿态。
  2. 平台/系统上下文豁免——IsPlatformContext()(tid==0)和IsSystemContext()看所有租户,给 SaaS 运营后台和后台 job 留了口子。

写入拦截(EvalMutation)——强制覆盖 tenant_id:

go

代码解读

复制代码

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 从哪来:JWT

UserViewer在 auth 中间件里从 JWT 解析构建(backend/pkg/middleware/auth/auth.go):

go

代码解读

复制代码

if op.injectEnt { userViewer := appViewer.NewUserViewer( uint64(tokenPayload.GetUserId()), uint64(tokenPayload.GetTenantId()), // 租户身份来自 JWT uint64(tokenPayload.GetOrgUnitId()), traceID, tokenPayload.GetDataScope(), ) ctx = viewer.WithContext(ctx, userViewer) }

viewer(pkg/entgo/viewer/user_viewer.go)暴露TenantID()/IsPlatformContext()(tid==0) /IsTenantContext()(tid>0) /DataScope()。还有一个SystemViewerIsSystemContext()==true,tid==0),给后台 job 绕过租户过滤用。

还有第二个等价注入点pkg/middleware/ent/ent.goent.Server(),从 operator-metadata 重建 viewer(给那些走 metadata 而非 auth 中间件的内部调用用),注册在grpc_server.go

go

代码解读

复制代码

ms = append(ms, ent.Server())

关系层的隔离是"基础设施级"的——开发者写client.User.Query().All(ctx)时不用记得加WHERE tenant_id,privacy 策略自动加;忘了注入 viewer 会直接报错而非泄漏。这是 ent + privacy 扩展的威力。

三、OLAP 层:手工 SQL,opt-in 裁剪

OLAP repo完全绕过 ent,直接用go-crudclient 跟引擎对话。租户隔离在这里是手工的、查询级的,而且来源是请求里的 appId,不是 viewer context

3.1 逐查询拼接

每个分析方法都重复这个模式(doris/analytics_repo.go,24 个方法都有):

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 ⚠️ 这是诚实的非对称

这是整个平台安全模型里最需要被理解的一点。跟关系层对比:

维度关系层(ent)OLAP 层
隔离来源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 '租户 ID(SaaS 多租户隔离,所有查询必须带此条件)',

"所有查询必须带此条件"——这是靠约定,不是靠强制。做 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 解析到应用记录 → 返回应用所属租户)。

三道闸的分工:

  1. 采集端(Collector):appId 鉴权 → 权威覆盖 tenantId。防"上报伪造"。
  2. 关系层(ent):JWT viewer → TenantPrivacy 自动过滤。防"配置数据越权读写"。
  3. OLAP 层(Core):appId → 手工拼tenant_id = ?。防"分析数据跨租户查询"。

第 1、2 道是默认安全的;第 3 道靠约定。三者组合,整体安全模型成立。

五、鉴权与权限:JWT + Casbin

除了租户隔离,平台还有完整的认证授权体系。

5.1 双轨认证
场景机制凭证位置
管理后台登录JWT(HS256)Authorization Header
SDK 上报appId + appSecret请求 body(为了 sendBeacon)

两套机制服务两类客户端:管理后台是人(用 JWT),SDK 是程序(用应用凭证)。权限粒度也不同——后台到按钮级,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 比较——防时序攻击。
  • 负缓存——防缓存穿透爆破。
  • 可用性≠鉴权失败——网络抖动不误报为密码错。
  • 状态检查——禁用应用拒绝上报。

这些每一项都防一类真实攻击,组合起来才是生产级。

http://www.jsqmd.com/news/1112509/

相关文章:

  • Skywalking分布式监控部署与SpringBoot集成实战
  • 【计算机Java毕业设计案例】基于 SpringBoot 的水务应急预案管理与智能调度系统的设计与实现 基于 SpringBoot 的水务运行大数据分析与应急决策系统(程序+文档+讲解+定制)
  • 【每天认识一个国家 | 法国】
  • 医养智伴APP的设计与开发
  • 情绪类 AI 的安全分级:先识别风险,再决定回应方式
  • Device Tree 调试:外设不工作,先别急着改驱动
  • AI 后端队列背压:请求堆住时,系统要会说不
  • Java计算机毕设之基于学习行为分析的自适应课程推荐系统的设计与实现 基于 SpringBoot 的在线教学资源个性化推荐系统(完整前后端代码+说明文档+LW,调试定制等)
  • 从零到一开发「天才厨神」美食烹饪小程序:架构设计与踩坑记录
  • AI 视觉回归评审:截图对比之外还要读懂界面意图
  • 微信小程序开发一个多少钱?附教程+5款国内外小程序开发工具实测(2026年7月更新)含零代码SAAS、AI编程、源码定制交付
  • 3步实现专业级视频水印去除:智能算法让画面瞬间纯净如初
  • AI绘画LoRA微调实战:从原理到应用
  • 西门子PLC电机控制:SCL结构化编程实战
  • LLM 推理延迟监控体系:从 Metrics 采集到 SLO 驱动的告警策略
  • 边缘模型 OTA:更新模型前,先准备好回滚
  • 智能服务网格灰度:策略建议可以 AI 化,执行必须可回滚
  • 资讯复盘:7月首个交易日A股科技股集体跳水
  • AI 工作流运营指标:别只看自动化率
  • AI 性能压测分析:让模型读报告,不要让它替你下结论
  • 兵棋推演系统:兵棋推演模拟软件
  • 算法之链表2
  • 工程方法领域:
  • 【CANdelaStudio-从入门到深入到实战】96 诊断刷写黑盒测试:如何用Python自动验证CANdela服务行为
  • H5 到底能不能做视频直播?
  • 独立产品数据模型:小型 SaaS 也需要清楚的边界
  • 2026 Agent 模型选型实战:Sonnet 5 vs Opus 4.8 + 28 模型横评数据全解
  • Flutter 状态动画:让变化顺滑,但不要重建整棵树
  • 哈希表题解:O(1) 查询背后也有边界
  • 基于Scrcpy与ADB的轻量级Android自动化测试方案实践