一个审计员要复核 AI 做过什么。
最省事的做法,是给他一个管理员账号。但这样一来,他要复核的那个操作者,就成了他自己。
这篇文章讲我们怎么建一个只读的审计角色:它的可达面是一份写死的清单,每个字段都从白名单重建,行范围来自账号而不是请求,而签名证据仍然保持完整、不被投影。
一、给 admin 最省事,也最错
审计读者的工作是复核别人做过什么。他需要的是证据,不是操作权。
但大多数系统的读权限是跟着写权限走的:能管一切的人当然能读一切。于是「让他能看审计」最短的路径就是给他admin。代价是:他同时获得了改策略、批审批、执行工具的能力——而他正是来复核这些动作的人。
所以这里要建的不是「一个更弱的 admin」,而是一个可达面可以逐项列出来的角色。列不出来,就说明它还是一个 admin。
二、角色的可达面,是一份清单
我们的审计角色只拿到五条规则,每条都只授予read:
{ roleCode: UserRole.AUDITOR, subject: 'AuditLog', actions: ['read'], ownerField: null }, { roleCode: UserRole.AUDITOR, subject: 'OperationAudit', actions: ['read'], ownerField: null }, { roleCode: UserRole.AUDITOR, subject: 'GovernancePolicy', actions: ['read'],ownerField: null }, { roleCode: UserRole.AUDITOR, subject: 'ToolInventory', actions: ['read'], ownerField: null }, { roleCode: UserRole.AUDITOR, subject: 'ApprovalQueue', actions: ['read'], ownerField: null },这份清单就是该角色能碰到的全部。其余由策略守卫默认拒绝。
它有意不拿任何USER的所有权规则:这个角色是治理记录的读者,不是应用的用户。它也不该有写动作——任何一条规则日后长出write,它就不再是审计读者。
这条规则的形状也值得说一句:底层的角色种子原本只有「主体 + 所有者字段」,每条隐含manage。我们给种子加了一个可选的actions;省略它仍然是manage(对既有角色零行为变化),写上才收窄。这样「读」是显式写出来的,不是从「管理」里推出来的。
三、字段:从白名单重建,不从整行删除
角色管的是「能到哪一面」,字段管的是「在这一面能看到什么」。
我们的做法是一份白名单,投影从它重建:
- 只写名单里点名的键;
- 明天给行加一个新列,在有人有意把它加进名单之前,审计读者看不到它;
- 反过来「从整行里删掉不许看的字段」会 fail-open——下一个新列默认就漏了。
这条差别不是风格问题,是默认值问题。从整行删,默认是可见;从白名单建,默认是不可见。
有意不在名单里的字段,各有理由:对话 id 是指向某个人的对话的指针;ip / 设备 id 把活动绑到一台设备或一个人;用户对助手的反馈不是审计记录;委派上下文是自由文本,可能引用提示词。读者判断一次治理决策需要的归因,由有界的业务意图与 agent / caller 标识承担。
我们有一条测试,在运行时往行里塞一个名单上没有的键,断言它不会出现在投影结果里。这不是测「删得对不对」,是测默认值。
四、行:组织来自账号,期间来自请求
角色和字段之外,还有「哪些行」。同一面、同样的字段,不同的人能看到的行也必须不同。
- 组织来自账号,不是来自请求。请求里的组织参数只能再收紧(与账号范围取 AND),不能替换它。
- 不属于任何组织的审计读者,退回本人的行;组织查询失败也退回本人——查询失败不是放宽的理由。
- 期间来自请求窗口。今天没有按读者配置的期间属性,也没有读平面策略;硬造一个就是发明维度,所以我们如实只用请求的那一段。
- 按 id 取单行时,范围外一律 404,与「没有这一行」同一个答案。闸已经过了,再告诉读者「另一个组织有这一行」本身就是一次披露。
按 id 与列表调同一个范围函数。两条路径各算一遍,迟早会各说各的。
五、聚合也要收窄,缓存键也要带上范围
列表收窄了,统计没收窄,等于没做。
审计读者的统计、成本、报告、覆盖声明,都按同一个组织范围取数。这里有一处容易被漏掉:聚合有 60 秒缓存,缓存键必须带上范围。
我们踩过这一类:键里没有范围时,先来的人的组织答案会在 60 秒内端给下一个读者。修法不是清缓存,是把范围写进键(all/org:<id>/own:<id>)。这与「一个数据库的校验结论被端给另一个数据库」是同一类缺陷:缓存不认人,键就得认。
有一处我们不按组织切:签名证据包里的哈希链。切片验链会得出假结论,所以链保持完整口径。范围可以收窄行,不能收窄一条链。
六、哪些面仍然关着
把审计读者能到的面列出来,同样重要的是列出他仍然到不了的:
| 面 | 审计读者 | 管理员 |
|---|---|---|
| 审计日志 / 单事件解释 / 链 / 校验 / 统计 / 成本 / 报告 | 读(行投影 + 组织范围) | 读(整行) |
| 操作审计 | 读(行范围尚未按组织收窄) | 读 |
| 治理策略 / 历史 / 工具清单 / 审批队列 | 读 | 读 + 写 + 裁决 |
| 签名证据包 / 链校验载荷 | 读(不投影,保持可验) | 读 |
| 用户 / 组织 / 会话 / 平台统计 / 写 / 审批 / 执行 | 不可达 | 可达 |
三处要专门说:
- 签名证据包与链校验载荷不投影。它们是核验物,价值就在完整的 canonical 载荷上;投影掉就没法验了。隐私投影覆盖的是行形状的响应,不是证据本身。
- 审计读者能读审批队列,不能裁决。批准、拒绝、重试执行仍然是管理员专属;给他读队列,不是给他处理队列。同样,流程审批人的角色清单里有意不包含 auditor——审计读者不能成为他正在复核的那种人。
- 操作审计的读权限开了,行范围还没跟上。它的服务只按请求里的
userId过滤,没有套审计读者的账号范围(组织)。这是 AW-2 的余项,写在这里,免得「五条规则」被读成「所有面都已按范围收窄」。
七、现场:一面一面地放宽
我们的第一刀,只把审计控制器改成read AuditLog。操作审计仍然判manage all,审计读者到那一面试了一下,403。
那不是漏做,是有意的:先把一面开出来,看它够不够,再一面一面地放宽。后来操作审计、治理策略、工具清单、审批队列各自拿到了自己的read主体;写、审批、执行始终不在放宽范围内。
这个顺序还有一个好处:每开一面,都能单独验证「他该看到的看到了、该挡的挡住了」。一次全开,出了洞也分不清是哪一面的。
八、一个你可以自己做的检验
五问:
- 你的只读角色能碰到哪些面?是一份能逐项列出的清单,还是「跟 admin 差不多」?
- 新加的字段默认可见吗?从整行删是 fail-open;从白名单建才是 fail-closed。
- 行范围来自账号还是请求?请求只能收紧吗?查不到组织时退到哪?
- 聚合与缓存带上同一个范围了吗?列表收窄、统计不收窄,等于没做。
- 签名证据被投影了吗?投影之后还验得动吗?
结语
回到开头:给一个人读证据,不等于给他整个系统。
我们做的是:五条只读规则构成角色的全部可达面;字段从白名单重建,新列默认不可见;组织来自账号、期间来自请求;范围外的单行返回 404;聚合与缓存带上同一个范围;签名证据保持完整。
下面这套取自开源项目 KeelBase 的实现—— 一个夹在 AI Agent 与企业业务系统之间的运行时,在工具执行前重新检查身份、授权、范围、风险与确认状态,并把每次决策写成可离线核验的记录。
仓库(境内访问建议用 Gitee 镜像):
- GitHub:GitHub - rain6fish/KeelBase: Open-source AI application engineering platform for building business-safe AI applications from existing systems or new business models—with governance, auditability, and private deployment built in. Existing System / New Business → Protocol → AI Application → Business-safe Agent → Governance → Audit → Private Deploy · GitHub
- Gitee:https://gitee.com/rain6fish/KeelBase
照实交代四处。一是这套角色是一面一面放宽的:清单之外的面仍然不可达,那不是没做完,是默认拒绝。二是签名证据不能被投影,所以「隐私最小化」在这一层有明确边界。三是审计读者不能裁决、不能当流程审批人——一旦能,他就变成了他正在复核的人。四是操作审计的行范围还没按组织收窄:读权限开了,范围还没跟上,这是已知余项,不是已经完成。
只读不是「权限小一点」,是「可达面能逐项写出来」。