AI Agent 权限检查跑在哪一层:查询下推、字段脱敏与工具闸门
该不该守权限已经没有争议;决定这道边界真假的是检查跑在哪一层。数据进入模型上下文之后再过滤,等于没过滤——那是纸面权限。本文拆开查询、字段、工具闸门三个执行点,以及平台明确不兜底的两处。
先给结论:agent 该不该受权限约束,这件事已经不用争了。真正决定这道边界是真是假的,是检查跑在哪一层——在数据进入模型上下文之前,还是之后。之后才补的过滤不是边界,是纸面权限:写下来像访问控制,运行时什么都不做。所以采购和评审时该问的不是”你们有没有权限模型”,而是”你们能不能证明某一条权限不是纸做的”。
“agent 到底该不该继承用户身份、该不该受审批和审计约束”,我们在 AI Agent 数据安全边界:如何在企业权限内工作 里单独论证过。这篇默认你已经同意那一层,只问后面那个更难回答的问题:这道边界具体落在哪一行代码上,以及它失效的时候,你能看见吗?
一条红线
把一次 agent 请求摊平来看,中间有一条线:数据进入模型上下文的那一刻。
线的左边,权限还能被强制执行——不该出现的行可以根本不产生,不该看的字段可以在离开数据层时就被替换掉。线的右边,一切都变成了”希望”:希望模型不要复述、希望它按提示词自觉、希望摘要里不会把几个字段拼成一句自然的话。
企业里最贵的那类错误,不是这条线画错了,而是没人知道自己已经站在线的右边。因为纸面权限从来不报错。它在元数据里读起来是治理,在评审里能过,在运行时什么都不做。
下面三种失效,都是真实存在的形态。共同点是:它们不会让系统崩掉,只会让你以为自己有边界。
失效一:写下来像授权,跑起来像”这条授权不存在”
行级权限在 ObjectStack 里是可声明的元数据:读用 permissions[].rowLevelSecurity[].using,写用 .check。每一条都是一个表达式,引擎把它降解成过滤条件,压进查询里。
问题在于,不是每个表达式都降得下去。可下推的子集是有边界的:比较运算符、in、&&/||/!、空值判断,以及 startsWith/endsWith/contains 这几个字符串方法,作用在单列字段路径上,与一个字面量或 current_user.* 的值相比较。函数调用、算术、三元表达式、跨对象路径(record.account.region 这种),都在子集之外。
一条降不下去的策略,接下来会发生什么,值得完整看一遍:
- 这条策略不贡献任何过滤条件。
- 读路径上,如果它是该对象该操作唯一适用的策略,运行时会换上一个拒绝哨兵——一个保留的、不可能命中的 id(
__rls_deny__:00000000-0000-0000-0000-000000000000)——AND 进 where 子句。于是这个对象上的每一次查询、更新、删除都匹配零行。 - 但如果还有别的策略也适用,情况更隐蔽:这一条只是从 OR 里悄悄消失,它写着要授予的那部分访问权,从来就不存在。没有报错,没有零行,只是有一批人一直看不到他们本该看到的数据,而没人把这件事和那行元数据联系起来。
- 写路径上,
check变成同一个哨兵,任何记录都无法满足,写入直接报权限错误。 - 运行时唯一的信号,是请求时的一行警告:这条策略”有无法编译的谓词,已被丢弃(不强制)”。
注意这个失效的形状:你写下的是一条授权,系统的行为是全面拒绝(或者更糟,是一次静默的不授予)。而在你写下它的那一刻,没有任何东西指向那一行。
ObjectStack 的答案是把它变成编译期的红灯。构建时有一条规则会走遍每一条声明出来的 using 和 check,把”永远不会生效”的谓词按 error 拒掉。
这条规则里真正值得抄的设计,不是它拒了什么,而是它怎么判断:它不去模仿运行时的行为,也不做模式匹配,而是直接调用运行时自己的那个判定函数,用同一个输入。运行时决定”这条策略是作者写错了还是有意跳过”,调用的是同一个函数。于是”被 linter 拒掉”和”被运行时丢弃”是同一个布尔值,不可能各自漂移。
这一点是关键。一个自己重新实现了被检规则的 linter,早晚会和它要检的东西意见不合,而误报方向比漏报更糟——把运行时执行得好好的策略拒掉,会让人开始绕过这条规则。这条规则上线时,平台自己的种子数据、示例和 dogfood 夹具里声明过的每一条 using 和 check 都是通过的:它没有把任何一件本来就正常的事情变红。
它还把诊断拆成了三个不同的 id,因为后果一样,修法不一样:谓词能解析但在可下推子集之外(改写它,或者把值反规范化到本对象上);连 CEL 都解析不了(把 SQL 式的 AND/OR/LIKE 改写成 CEL);语法完全正确但超出了平台的解析预算上限(拆小它)。一个只会说”这条不行”的检查,会把人引向错误的修法。
还有一个容易被漏掉的细节:enabled: false 的策略照样会被判。今天它是关着的,后果只是休眠而不是活跃;但一条永远无法生效的策略,会在某人把开关打开的那天什么都不授予——而那一刻恰恰不会有人重跑 linter。
这才是该向平台索要的东西。 不是”你们支不支持行级权限”——所有人都说支持。而是:你们的构建能不能拒绝一条编译后什么都不做的安全规则?
失效二:等你过滤的时候,模型已经读到了
记录范围是一个数据库去求值的谓词,还是一个对结果做的过滤,听起来像是实现细节,直到你跟着一次 agent 请求走一遍。
如果工具先宽查询、再在结果上过滤,那些越界的行已经真实存在过:它们在进程内存里,而且取决于过滤发生在哪一步,很可能已经被序列化进了模型的上下文。那一刻起,权限决策变成了追认。你不是在守边界,你是在指望后面没人复述已经读到的东西。
压进查询里,这些行根本不会被产生。没有东西可泄露,没有东西需要事后补救,也不依赖模型的表现。
ObjectStack 里 agent 的数据工具从不直接碰引擎。它们走的是和 REST API 同一条权限与行级权限强制路径,绑定在调用者的主体身份上,工具无法把它放宽。“agent 有一个自己的服务账号”和”agent 以当前登录用户的身份查询”是两种架构不同的产品,只有后一种在出错时是安全地退化。另外,系统对象(sys_*)默认不暴露给 agent 工具,这道守卫挂在每一个接受对象名的工具上,独立于桥接层——也就是说,即使桥接层配置错了,这一层也还在。
字段:屏蔽跟着值走,不跟着声明的类型走
能看到一条记录,不等于能看到它的每个字段。客户记录里的内部风险评分、商机里的底价和利润率、合同里的敏感条款——用户在界面上看不到,agent 也不该拿到。而且 agent 比界面更危险:摘要恰恰是把多个字段重新组合成流畅句子的那种操作。
所以字段级安全必须发生在值被序列化、送往模型之前,而不是变成一句”不要说出来”的叮嘱。ObjectStack 的实现里有四个细节,不管你在什么平台上做,都值得照抄:
- 不可读的字段被删掉,需要脱敏的字段被替换掉——都发生在结果离开数据层的时候。前者从结果里消失,后者变成掩码值(手机号保留前 3 后 4,身份证保留前 6 后 4,银行账号只保留后 4 位)。
- 掩码跟着值走,不跟着声明的类型走。 数字和大整数按它们的十进制文本脱敏——一个用数字类型存的手机号,不会因为”它是 number 不是 string”这种技术细节而漏出去。太短、保不住首尾的值,一律整个遮掉;不认识的脱敏预设,也是整个遮掉。
- 没见过的形状一律失败关闭。 布尔、对象、日期这些规则本来没为它们写过的形状,会塌缩成一个固定的、与值无关的遮蔽符号——连长度和”是不是真”这一个比特都不会漏出来。
- 聚合按输入闸门。 没有这一条,agent 只要把一个被遮蔽的字段拿去求个平均,就能把秘密当成统计量读回来。
脱敏是幂等的(对已经脱敏的值再脱敏,结果不变),这一点让另一件事成立:系统可以识别并拒绝”把掩码值原样写回去”的写入。一个整条记录回填的客户端,会把 138****5678 当成真实手机号提交,把库里的真值覆盖掉——平台在这里返回 400 拒绝,而不是照写。
拒绝要看得见
还有一个更像产品决策、但影响很大的细节:当写入触及一个用户无权编辑的字段时,平台返回 403,而不是把这些字段悄悄丢掉。
理由值得抄给任何做后台的人:静默丢弃会把安全边界藏起来。诚实的客户端只会看到”我的修改有一部分没存上”,而且不知道为什么;攻击者则得不到任何否定信号,也就无从判断这个字段是否存在。抛错让边界在两个方向上都可观测。
这条原则对 agent 尤其重要。一个被静默丢弃的写入,在 agent 眼里是一次成功——它会据此往下走,告诉用户已经改好了。
失效三:作者以为自己设了一个开关
一个动作不会因为它存在就自动开放给 AI。在 ObjectStack 里,AI 暴露是动作上一个显式的选择块,而它的默认值写死在 schema 里:exposed 默认 false——默认拒绝,写在 schema 上,不是写在文档里。
比默认值本身更值得注意的是这道闸门的两个性质:
- 把
exposed设为true,强制要求一段面向大模型的description,且至少 40 个字符。你没法在不说明”什么时候、为什么该调用它”的前提下,把一个动作武装给整个 agent 队列。工具契约是被人写下来的,不是从界面按钮的标签里推导出来的。 - 这个块是严格模式的,而且那些”读起来像开关、其实不是开关”的键,会被重命名到真正的那个键上,而不是被静默丢弃。这是平台反复撞到的失效形态:作者设了一个自己以为是闸门的键,而这个键根本不存在。有五种拼写会落到
exposed上——包括enabled、expose,以及最像的那个visible。在这个形状被关严之前,它们是被静默丢弃的:动作照样注册、照样运行,只是那个键本来要管的事情没人管。
同一个原则也管界面:visible 和 disabled 是 UI 谓词,它们隐藏或置灰一个按钮。隐藏不是拦截——按钮没了,路由还在。真正的闸门是 requiredPermissions,在平台动作路由上以 403 强制执行。一个看不见、也没被闸门管住的控件,是一条很有礼貌的纸面权限。
这里还有一个更常见的混淆:三道闸门是三件事,不要指望在一个键上把它们都办了。
| 你想管的事 | 该用的东西 |
|---|---|
| 谁有资格调用这个动作 | 动作上的 requiredPermissions(403) |
| agent 手里到底有没有这个工具 | 由 skill 声明;谁能跟这个 agent 说话,由 agent 自己的访问与权限设置管 |
| 这一次调用要不要人点头 | requiresConfirmation——AI 调用上的人工介入 |
| 一条多步的业务审批 | 一个独立的审批元数据项,不是动作上的一个字段 |
把审批塞进动作字段里,或者以为”AI 能不能调”是 ai 块里的某个权限键,是同一类错误:它们看起来是治理,实际上没有落在任何一个执行点上。
平台没有兜底的两处
这套架构解决不了的事情,说清楚比说满更有用。
第一,动作体一旦开始执行,就是可信应用代码。 对象的读写在每一次调用上都是有界的——行级权限管行,字段级安全管列。动作体不是。它里面没有数据层的兜底。一个直接加载记录再返回的动作,可以把任何东西交给 agent,没有任何行过滤会拦住它。
这恰恰是那个独立的 AI 开关为什么要存在。更早的设计是”不要单独的 AI 开关,靠权限和行级权限强制就够了”——理由是既然每一次读都是有界的,暴露就是无害的。这个设计被明确推翻了,因为过了动作边界,它就不成立。这个开关和能力闸门,替代的正是那里不可能存在的数据层检查。
现实的推论只有一句:开放给 AI 的动作,是被评审过的动作。 行级权限不会替你评审它。如果一个平台告诉你”有权限就够了,暴露动作是安全的”,它没有走过这条路径。
第二,服务端的能力检查只覆盖平台自己的调用路径。 requiredPermissions 的 403,发生在平台动作路由(POST /api/v1/actions/{对象}/{动作})和 AI 调用路径上——脚本、流程、弹窗这三类动作都落在这里。但一个 type: 'api' 的动作,如果它指向的是你自己写的端点,那是浏览器直连的:平台根本看不到这个请求,也就没有任何东西在服务端检查这条声明。
所以在那种动作上,界面上的闸门只是一种礼貌,不是边界——那个端点必须自己再检查一次能力。这不是缺陷,是边界的位置:平台只能强制它自己经手的调用。但如果没人把这句话说给你听,你会以为那条 requiredPermissions 在保护你。
验收清单:让对方演示一次失败
评估平台(或者评审你自己的代码库)的时候,是/否的问题没什么用,因为所有人的答案都是”是”。改成要求演示一次失败,答案立刻会分层。
| 要求对方演示的 | 合格的样子 | 该警惕的信号 |
|---|---|---|
| 写一条永远不会生效的行级权限规则 | 构建报错,并指出是哪一行、该怎么改 | 构建通过,“运行时会处理” |
| 让 agent 查一批越权数据 | 数据库层就返回不了这些行 | 能查出来,然后”在返回前过滤掉” |
| 让 agent 摘要一个被遮蔽的字段 | 值在进入上下文前已是掩码 | 提示词里写着”不要泄露该字段” |
| 让 agent 对被遮蔽字段做一次平均 | 聚合在输入侧被拒 | 统计值算得出来 |
| 新建一个动作,什么都不配就问 agent 能不能调 | 默认调不到 | 默认能调,需要显式关掉 |
| 打开一个动作的 AI 开关,但不写说明 | 拒绝保存 | 保存成功 |
| 问”动作体里跑的东西谁审的” | 有明确的评审约定 | ”权限已经管住了” |
前六项都答得好、第七项答不上来的平台,是没有想过 agent 的。第一项答不上来的平台,它的行级权限是纸做的。
为什么这归根到底是元数据问题
上面每一个执行点都是声明出来的,不是写在代码里的:权限集上的一个谓词、字段上的一条脱敏规则、动作上的一个选择块。这才让它们能作为 diff 被评审、被构建检查——也正因如此,那条编译期的红灯才可能存在。你没法给一个只存在于命令式处理函数里的授权做 lint。
当代码是 agent 写的而不是人写的时候,这一点会变得更要紧。评审的人不是在读一个满是查询拼装的 PR,去确认十七条路径里都带上了租户过滤条件。他读的是一个权限集,剩下的由运行时供给,而构建会拒掉那些纸做的权限。
如果你这个季度打算把编码 agent 指向某个后端,要挑的属性不是它搭 CRUD 有多快,而是它产出的权限能不能在上线之前被证明会生效。把你的 agent 指向 ObjectStack 规范,然后审它声明了什么。