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
136 lines
3.8 KiB
Go
136 lines
3.8 KiB
Go
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
|
||
}
|