audit — 审计日志与可插拔 sink

pkg/core/audit 记录谁对什么资源做了什么、结果如何—— 合规审查和事故排查读的那条线索。默认一条事件变成一行结构化日志; 用 audit.SetSink 可以把它改道到一个持久 sink(审计表、 只追加的 Kafka topic、不可变文件)。

运行

完全离线,不需要 make dev。HTTP 默认监听 :8086, 配置文件是目录里的 config.yaml,-conf 默认指向它。 写端点要求请求头 X-API-Key(默认值 demo-key)。

go run ./examples/audit           # 等价于 make example NAME=audit

它演示了什么

记录一个业务动作

来自 examples/audit/main.go 的 refund。 注意记录在 return 之前写下,所以审计轨迹与调用方看到的响应一致; 失败路径带一个 Detail 说明原因。

s.store.mu.Lock()
done := s.store.refunded[id]
s.store.mu.Unlock()
if done {
	audit.Record(ctx, audit.Event{
		Actor: actor, Action: "order.refund", Resource: resource, Result: audit.Failure,
		Detail: map[string]string{"reason": "already refunded"},
	})
	return nil, errs.Conflict(40901, "order already refunded")
}
s.store.mu.Lock()
s.store.refunded[id] = true
s.store.mu.Unlock()
audit.Record(ctx, audit.Event{
	Actor: actor, Action: "order.refund", Resource: resource, Result: audit.Success,
})
return map[string]any{"refunded": id}, nil

装上自定义 sink

来自 Setup。示例的 orderStore 既是业务存储又是审计 sink;真实服务在这里换成数据库写入,同步写并上报错误,否则轨迹会有洞。

// A second destination beside the default log sink: the demo keeps the
// trail in memory so it can be read back. A real service writes to a
// table or an append-only topic here — synchronously, and reporting
// write errors, or the trail has holes.
s.store = &orderStore{refunded: map[int64]bool{}}
audit.SetSink(s.store)
flowchart LR
    B["业务动作成功 / 被拒"] --> R["audit.Record(ctx, Event)"]
    R --> S{"SetSink 装过吗?"}
    S -- "否(默认)" --> L["logSink:obs.Info
audit_actor / audit_action
audit_resource / audit_result"] S -- "是" --> D["自定义 Sink.Write(ctx, Event)
审计表 / DLQ topic / WORM 文件"] R -. "补 At 与 TraceID" .-> R classDef plug fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class S,D plug
sink 的插拔点。Record 负责把字段补全并盖时间戳,然后同步 调 Sink.Write;顺序、保留期、不可篡改与访问控制都由 sink 负责。

验证

curl -i -X POST localhost:8086/api/v1/orders/42/refund -H 'X-API-Key: demo-key'   # 200
curl -i -X POST localhost:8086/api/v1/orders/42/refund -H 'X-API-Key: demo-key'   # 409,同样入审计
curl -s localhost:8086/api/v1/audit                                              # 内存里的审计轨迹

第一次返回 {"code":0,"msg":"ok","data":{"refunded":42}}; 第二次是 409 {"code":40901,"msg":"order already refunded"}。 GET /api/v1/audit 返回一个数组,每项的字段与值形如 {"at":"…" "actor":"anonymous" "action":"order.refund" "resource":"order/42" "result":"success" "detail":null "trace_id":"…"}; 失败那条 result 为 "failure" 且 detail 含 reason。加 -H 'X-User: alice' 可把 actor 从 anonymous 改成 alice。

测试

main_test.go 完全离线:它按 verticle 的方式跑 Setup(装上 sink)再 BuildRouter,用 httptest 发请求。它钉住三件事: 每个结果各只记一条(一次成功 + 一次 409 失败,共 2 条, 成功事件字段正确、失败事件带 Detail,且事件都被盖了时间与 trace id); 鉴权拒绝不是业务动作(401 后审计表为空); 审计端点如实回放(POST 退款 order/7 后, trail 里能查到 "resource":"order/7" 与 "actor":"bob")。

go test ./examples/audit

相关