
一个审计员要复核 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 镜像GitHubGitHub - 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 · GitHubGiteehttps://gitee.com/rain6fish/KeelBase照实交代四处。一是这套角色是一面一面放宽的清单之外的面仍然不可达那不是没做完是默认拒绝。二是签名证据不能被投影所以「隐私最小化」在这一层有明确边界。三是审计读者不能裁决、不能当流程审批人——一旦能他就变成了他正在复核的人。四是操作审计的行范围还没按组织收窄读权限开了范围还没跟上这是已知余项不是已经完成。只读不是「权限小一点」是「可达面能逐项写出来」。