2022 年内部平台出现过一次权限争议:一个项目的发布权限被移除,团队问“谁在什么时候改的,改之前是什么”。应用日志里能找到 update role success,却没有操作者、资源范围和变更前值。日志证明某段代码执行过,不能证明业务事实怎样变化。
从那以后我们把审计日志从普通文本日志里拆出来。它面向追责、合规和事故复盘,需要稳定结构、受控访问和独立保留策略。
图 1:一条审计记录至少要能重建“谁、在什么上下文、对什么资源、做了什么、结果如何”。
记录业务动作,不记录 Controller 名
type AuditEvent = {
id: string;
at: string;
actor: { type: "user" | "service"; id: string; tenantId: string };
action: "project.member.role_changed";
resource: { type: "project_member"; id: string; projectId: string };
before: { role: string };
after: { role: string };
reason?: string;
requestId: string;
sourceIp?: string;
result: "succeeded" | "denied" | "failed";
};POST /role/update 会随着路由重构变化,project.member.role_changed 表达的是长期业务语义。查询“所有管理员权限变更”不应该依赖某个年代的接口路径。
审计与业务写入共享提交边界
如果先改权限再异步写审计,进程崩溃可能留下“变更成功但没有记录”。我们在同一数据库事务写业务数据和 outbox 审计事件,独立消费者把它复制到审计存储:
BEGIN;
UPDATE project_members SET role = :new_role WHERE id = :member_id;
INSERT INTO audit_outbox (event_id, payload) VALUES (:event_id, :payload);
COMMIT;这样审计平台短暂不可用不会阻塞核心事务,outbox 也不会丢。消费者去重 eventId,写入追加型存储,普通应用账号没有更新和删除权限。
before/after 需要脱敏和控制体积
完整对象差异很方便,却可能包含 token、手机号或大段配置。我们按资源类型维护字段白名单,只记录与动作相关的值。秘密字段记录“是否发生变化”,不记录内容。
| 字段 | 审计值 |
|---|---|
role |
developer -> admin |
apiToken |
changed: true |
phone |
138****0217 或不记录 |
| 大型策略 JSON | 版本号 + 内容 hash + 独立受控快照 |
审计记录本身也是敏感数据,查看审计的行为也要被审计。
被拒绝的高风险动作也值得记录
只有成功操作会留下业务变化,但连续权限拒绝可能是攻击或错误自动化。我们记录关键 denied,包含策略版本和拒绝原因,不把每个普通 404 都灌入审计库。
{
"action": "production.deploy.requested",
"result": "denied",
"policyVersion": "rbac-42",
"reason": "missing: production.deploy",
"requestId": "req_8f2..."
}可查询性要在事故前验证
我们用几个固定问题验收模型:谁在某时间窗修改了项目 P 的生产权限;某用户离职前 24 小时执行过哪些高风险动作;一次 requestId 涉及哪些资源变更;某服务账号使用过哪些权限。
如果这些问题需要临时 grep 文本,审计结构还不够。上线后还监控 outbox 积压、事件写入延迟、字段解析失败和存储完整性校验。
那次权限争议最后无法从旧日志得到完整答案,这是一次真实的信息损失。审计日志的价值不是让系统“看起来合规”,而是在团队最需要事实时,不必靠聊天记录和人的记忆拼凑发生过什么。