AI Agent 落地实践 · 第 2 篇

2026-09-22 · 阿良 · 苏州畅达软件

多租户数据隔离:加了 tenantId 只是第一层

多租户数据隔离权限设计RBAC架构决策

我们在做一个面向基层公共卫生场景的 AI 智能体平台:管理台 + 多租户权限体系 + 五个业务模块 + 智能体桥接。 用户是多个区县,每个区县下有若干科室和社区。

产品做到第二期时,我们回头把数据隔离完整梳理了一遍,发现"每张表都加了 tenantId"这件事—— 只解决了问题的一半。


一、先分清两个层次

大多数人说起"多租户隔离",指的是第一层:

第一层:租户隔离 —— A 区县的数据,B 区县绝对看不到。

做法很标准:每张业务表带 tenantId,所有查询强制带上这个条件。

但真实业务里还有第二层,而且它比第一层更容易出事

第二层:租户内的数据范围 —— 同一个区县里,一个社区医生不该看到隔壁社区的数据。

为什么第二层更容易漏

因为第一层有天然的"守卫者":tenantId 是从登录态里取的,写查询时忘了带,通常测试阶段就会发现(A 租户登录看到了 B 租户的数据,很显眼)。

第二层不一样。它取决于当前用户的角色,同一个接口,不同角色要看到不同的数据子集。漏了不会报错,只会静静地多给一点数据——而多给的那部分,恰恰是别人不该看到的。

我们把这两层都固化成了产品级的不变量:

不变量一  业务数据按 tenantId 隔离;查询不得跨租户泄漏
不变量二  数据范围(TENANT_ALL / DEPARTMENT / COMMUNITY / OWNER / CUSTOM)
          必须在列表与详情同时生效

第二条里"列表与详情"这个限定词,是我们付出代价换来的。


二、五级数据范围

第二层的落点是一个叫 DataScope 的模型,把"能看到多少数据"归纳成五级:

级别 含义
TENANT_ALL 全租户(区县管理员)
DEPARTMENT 本部门及下级
COMMUNITY 本社区
OWNER 仅本人负责的数据
CUSTOM 自定义(按配置的节点集合)

角色和数据范围是解耦的:角色决定"能做什么"(权限点),数据范围决定"能看多少"(数据子集)。两者通过一张绑定关系关联起来。

用户 ── 角色绑定 ──┬── 角色(决定动作权限)
                   └── 数据范围(决定数据边界)

这个设计的价值在于:"能做什么"和"能看多少"是两个正交的维度。 一个社区医生和一个科室主任,可能拥有完全相同的操作权限,但能看到的数据范围差一个量级。如果把它们揉在一起,角色数量会爆炸。


三、三个最容易漏的地方

漏点一:详情页没做范围过滤

这是我们最早踩到的。

列表接口老老实实走了数据范围过滤,用户只看到自己该看的那些行。但详情接口是按 ID 直查的——它绕过了列表的过滤条件,只要知道 ID,就能拿到不属于自己范围的数据。

列表  GET /residents          → 走 scope where  → 只返回范围内的行
详情  GET /residents/{id}     → 按 ID 直查      → 范围外也能拿到 ⚠️

这就是为什么我们的不变量里明确写了"必须在列表与详情同时生效"。这不是描述实现,是在提醒后来者——这两处必须分别施加上去,不会自动继承。

修法很直接:把范围过滤做成一个 service 层的能力(DataScopeService),要求所有查数据的路径都经过它,而不是各处自己拼 where。拼 where 的人一多,总有一条路径会被忘掉。

漏点二:新表忘带 tenantId

这个漏点的特点是当时不报错,半年后爆炸

新增一张业务表,如果忘了带 tenantId,功能一切正常——直到某个查询按租户过滤时,发现这张表没有可过滤的字段。

我们的做法是把它变成结构约束,而不是靠代码审查的记忆:

硬边界:新业务表必须带 tenantId
       (同时预留 department / community / owner 三类归属字段)
守护方式:Prisma schema review,CI 里检查

注意后面那半句"预留三类归属字段"。因为二级范围可能按部门、按社区、按负责人三种维度划分,如果建表时没预留,后面想加就要改表 + 回填数据

我们的领域文档里有一张表,逐条列出每个数据对象"落哪条不变量":

ResidentRecord            → 不变量一
ResidentServiceTask       → 不变量一
DataScope                 → 不变量二
RoleBinding               → 不变量二
OpenClawScopeMapping      → 不变量四(Agent 权限映射)

好处是:新增对象时,你必须回答"它落哪条不变量",回答不出来就说明设计还没想清楚。

漏点三:查询条件本身没做白名单

这一条严格说不是隔离问题,但它和隔离是同一个根:别让客户端决定查什么。

我们的居民查询支持客户端传过滤条件(字段 + 操作符 + 值)。如果字段名不校验,客户端可以传任意字段——包括那些不该被外部过滤的、或者会暴露数据分布的字段。

做法是字段白名单 + 操作符白名单,白名单外一律拒绝,而且要在触发任何数据库查询之前就拒绝掉:

WHEN   提交 filter.conditions[].field = "__evil__"
THEN   返回 400,且不触发任何数据库查询

最后那半句很重要——不是"查完发现字段不合法再报错",而是根本不查。校验必须发生在查询之前,否则校验本身就成了一个探测接口。


四、当 Agent 也来访问数据时,多一层隔离

平台接了一个智能体引擎做对话和任务编排。它需要读业务数据,但它不该直接连业务库。

我们的做法是给它一条专用通道,并且这条通道上的签名密钥按租户隔离

每个租户可以配置专用签名密钥(环境变量注入)
缺专用密钥的租户回退到全局密钥
生产环境缺全局密钥 → fail-closed,直接拒绝

为什么要按租户隔离密钥?因为共用一个密钥时,任何一处泄漏都等于所有租户的通道都可被伪造。按租户分开之后,影响面被限制在单个租户内。

这里有个工程细节值得单独说:验签方不查数据库取密钥,而是从请求头里拿租户标识、直接读环境变量。这样做的原因是——如果验签要先查库,那验签这条路径本身就成了一个数据库入口,隔离又多了一个缺口。能用无状态方式做的校验,就不要引入数据依赖。


五、沉淀成四条

做完这一轮梳理,我们收敛出四条判断。它们和具体技术栈无关:

一、多租户要分两层做。

租户隔离(横向)和租户内数据范围(纵向)是两件事,后者更容易漏,因为它不报错,只是静静地多给一点数据。

二、数据范围必须在列表和详情分别施加。

不要指望"列表过滤了,详情自然安全"。详情通常按 ID 直查,是一条独立的路径。把它做成 service 层统一能力,而不是靠各处自觉。

三、把隔离要求变成结构约束,而不是审查记忆。

新表必须带 tenantId、必须预留归属字段——这类要求写进 schema 检查,比写进文档管用。文档会被忘,CI 不会。

四、所有来自客户端的"查询意图",都要经过白名单。

字段、操作符、排序方向——凡是客户端能指定的,都要能被列举出来。列举不出来的,就是攻击面。


一句话总结

加了 tenantId 让你挡住了隔壁公司;但挡住同一家公司里的隔壁科室,靠的是另一套东西。

而后者,往往才是真实业务里最先出问题的地方——因为它不报错。


作者:阿良 · 苏州畅达软件 —— 这是「AI Agent 落地实践」系列第 2 篇。下一篇写 Agent 操作审计:AI 做过的每件事,怎么留痕、留什么、给谁看。有类似场景欢迎交流。

准备开始您的数字化项目?

留下需求,我们会在一个工作日内与您联系。

联系我们