Files
修雨 d0787ce8ee fix(audit): stateless hash chain, waitable forwarder, profile actor, observability
Address PR #555 review:
- chain: derive prev_hash from file tail under cross-process flock, drop the
  global .chain sidecar so per-day files stay independently verifiable and
  concurrent dws processes cannot fork the chain
- forward: track async forwards with WaitGroup and add bounded Close(ctx) so
  in-flight deliveries are not dropped on process exit
- actor: resolve Actor from the active runtime profile (profile-keyed cache)
- observability: BuildSink returns init errors; write/forward failures reported
  to file log and to stderr when DWS_AUDIT_DEBUG is set
- cli: reject `audit tail --lines` < 1; check CSV writer/flush errors
- wire CloseAuditSink into PersistentPostRunE
- add regression tests for cross-date/cross-process chain, forwarder
  wait/timeout, init-failure, tail validation, CSV export
2026-07-14 11:56:09 +08:00

136 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package audit
import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
EnvAudit = "DWS_AUDIT"
EnvAuditDir = "DWS_AUDIT_DIR"
EnvRetentionDays = "DWS_AUDIT_RETENTION_DAYS"
EnvForwardURL = "DWS_AUDIT_FORWARD_URL"
EnvForwardToken = "DWS_AUDIT_FORWARD_TOKEN"
EnvForwardRedact = "DWS_AUDIT_FORWARD_REDACT"
EnvAuditDebug = "DWS_AUDIT_DEBUG"
defaultRetentionDays = 90
auditSubdir = "audit"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: EnvAudit,
Category: configmeta.CategoryAudit,
Description: "操作审计日志开关(默认启用,设 0/false/off 关闭)",
DefaultValue: "启用",
Example: "0",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvAuditDir,
Category: configmeta.CategoryAudit,
Description: "审计日志目录(默认 <configDir>/audit)",
Example: "/var/log/dws-audit",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvRetentionDays,
Category: configmeta.CategoryAudit,
Description: "审计日志留存天数",
DefaultValue: "90",
Example: "180",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvForwardURL,
Category: configmeta.CategoryAudit,
Description: "审计事件远端转发 URL(POST JSON)",
Example: "https://siem.example.com/audit",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvForwardToken,
Category: configmeta.CategoryAudit,
Description: "远端转发 Bearer Token",
Sensitive: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvForwardRedact,
Category: configmeta.CategoryAudit,
Description: "远端转发脱敏级别:none / hashed / minimal",
DefaultValue: "none",
Example: "hashed",
})
configmeta.Register(configmeta.ConfigItem{
Name: EnvAuditDebug,
Category: configmeta.CategoryAudit,
Description: "打印审计子系统初始化/写入/转发失败诊断到 stderr(设 1/true/on 开启)",
Example: "1",
})
}
// DebugEnabled reports whether audit-subsystem diagnostics should be surfaced to
// stderr. Failures are always eligible for the structured log; this gates the
// noisier stderr channel.
func DebugEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvAuditDebug))) {
case "1", "true", "on", "yes", "y":
return true
}
return false
}
func IsEnabled() bool {
v := os.Getenv(EnvAudit)
if v == "" {
return true
}
switch strings.ToLower(v) {
case "0", "false", "off", "no", "n":
return false
}
return true
}
// BuildSink constructs the audit sink for configDir. It returns an error when
// the audit subsystem is enabled but cannot initialize (e.g. the log directory
// is not writable) so the caller can surface the failure instead of silently
// degrading. report receives non-fatal forwarder diagnostics; it may be nil.
func BuildSink(configDir string, report func(format string, args ...any)) (Sink, error) {
if !IsEnabled() {
return NopSink{}, nil
}
dir := os.Getenv(EnvAuditDir)
if dir == "" {
dir = filepath.Join(configDir, auditSubdir)
}
retention := defaultRetentionDays
if v := os.Getenv(EnvRetentionDays); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
retention = n
}
}
writer, err := NewDateRotatingWriter(dir, retention)
if err != nil {
return NopSink{}, err
}
chain := NewChain(dir)
var forwarder *HTTPForwarder
if fwdURL := os.Getenv(EnvForwardURL); fwdURL != "" {
token := os.Getenv(EnvForwardToken)
redact := RedactLevel(strings.ToLower(os.Getenv(EnvForwardRedact)))
if redact != RedactHashed && redact != RedactMinimal {
redact = RedactNone
}
forwarder = NewHTTPForwarder(fwdURL, token, redact, report)
}
return NewFileSink(writer, chain, forwarder), nil
}