This commit is contained in:
parent
3af9250ffe
commit
9bdf866a87
101
backend/config/audit_changes.go
Normal file
101
backend/config/audit_changes.go
Normal file
@ -0,0 +1,101 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConfigAuditChange 只保存安全的显示值,不保存密文或请求原文。
|
||||
type ConfigAuditChange struct {
|
||||
Field string `json:"field"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
Sensitive bool `json:"sensitive,omitempty"`
|
||||
}
|
||||
|
||||
// AuditChanges 对比业务字段,排除代次、摘要和内部持久化元数据。
|
||||
func AuditChanges(before, after RuntimeConfig) []ConfigAuditChange {
|
||||
left := auditFields(before)
|
||||
right := auditFields(after)
|
||||
keys := make(map[string]bool)
|
||||
for key := range left { keys[key] = true }
|
||||
for key := range right { keys[key] = true }
|
||||
ordered := make([]string, 0, len(keys))
|
||||
for key := range keys { ordered = append(ordered, key) }
|
||||
sort.Strings(ordered)
|
||||
changes := make([]ConfigAuditChange, 0)
|
||||
for _, key := range ordered {
|
||||
if reflect.DeepEqual(left[key], right[key]) { continue }
|
||||
change := ConfigAuditChange{Field: key, Before: auditDisplay(left[key]), After: auditDisplay(right[key])}
|
||||
if auditSensitive(key) {
|
||||
change.Sensitive = true
|
||||
change.Before, change.After = "未配置", "未配置"
|
||||
if auditConfigured(left[key]) { change.Before = "已配置(隐藏)" }
|
||||
if auditConfigured(right[key]) { change.After = "已配置(隐藏,已更新)" }
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func auditFields(runtime RuntimeConfig) map[string]any {
|
||||
result := map[string]any{}
|
||||
for key, value := range map[string]any{
|
||||
"root": runtime.Root, "smtp": runtime.SMTP, "email": runtime.Email,
|
||||
"ssl": runtime.SSL, "plugins": runtime.Plugins,
|
||||
} {
|
||||
data, _ := json.Marshal(value)
|
||||
var object any
|
||||
_ = json.Unmarshal(data, &object)
|
||||
flattenAudit(result, key, object)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func flattenAudit(result map[string]any, path string, value any) {
|
||||
name := path[strings.LastIndex(path, ".")+1:]
|
||||
switch name {
|
||||
case "meta", "sections", "generation", "updatedAt", "schemaVersion", "configVersion", "version":
|
||||
return
|
||||
}
|
||||
if path == "root.smtp" || path == "root.email" || path == "root.ssl" || path == "root.plugins" { return }
|
||||
if auditSensitive(path) { result[path] = value; return }
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
for key, item := range object { flattenAudit(result, path+"."+key, item) }
|
||||
return
|
||||
}
|
||||
result[path] = value
|
||||
}
|
||||
|
||||
func auditSensitive(path string) bool {
|
||||
name := strings.ToLower(path[strings.LastIndex(path, ".")+1:])
|
||||
for _, word := range []string{"password", "secret", "token", "apikey", "api_key", "credential", "ciphertext", "authorization", "cookie"} {
|
||||
if strings.Contains(name, word) { return true }
|
||||
}
|
||||
// 插件设置是自由结构,未知键也不能作为明文审计输出。
|
||||
return strings.HasSuffix(path, ".settings") || strings.HasSuffix(path, ".encryptionKeyRef")
|
||||
}
|
||||
|
||||
func auditConfigured(value any) bool {
|
||||
if value == nil || value == "" { return false }
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
if ciphertext, exists := object["ciphertext"]; exists { return ciphertext != "" }
|
||||
return len(object) > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func auditDisplay(value any) string {
|
||||
if value == nil { return "未设置" }
|
||||
if text, ok := value.(string); ok {
|
||||
if len([]rune(text)) > 512 { return string([]rune(text)[:512])+"…" }
|
||||
return text
|
||||
}
|
||||
if flag, ok := value.(bool); ok { if flag { return "开启" }; return "关闭" }
|
||||
data, _ := json.Marshal(value)
|
||||
text := []rune(string(data))
|
||||
if len(text) > 512 { return string(text[:512])+"…" }
|
||||
return string(text)
|
||||
}
|
||||
@ -96,6 +96,8 @@ func (m *ConfigManager) SwitchDatabase(ctx context.Context, expectedGeneration u
|
||||
Section: SectionRoot,
|
||||
ActorID: actor.UserID,
|
||||
ActorRole: actor.Role,
|
||||
ActorName: actor.Name,
|
||||
Changes: AuditChanges(current, candidate),
|
||||
FromGeneration: current.Generation,
|
||||
ToGeneration: candidate.Generation,
|
||||
TargetGeneration: candidate.Generation,
|
||||
|
||||
@ -56,6 +56,7 @@ type UpdateRequest struct {
|
||||
|
||||
type AuditActor struct {
|
||||
UserID string
|
||||
Name string
|
||||
Role string
|
||||
SessionID string
|
||||
RecentAuth bool
|
||||
@ -469,6 +470,8 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi
|
||||
Section: pending.Section,
|
||||
ActorID: pending.Actor.UserID,
|
||||
ActorRole: pending.Actor.Role,
|
||||
ActorName: pending.Actor.Name,
|
||||
Changes: AuditChanges(current, candidate),
|
||||
FromGeneration: current.Generation,
|
||||
ToGeneration: candidate.Generation,
|
||||
ChangedFields: changedFields(current, candidate, pending.Section),
|
||||
@ -726,6 +729,8 @@ func (m *ConfigManager) rollbackWithState(ctx context.Context, generation uint64
|
||||
Section: SectionRoot,
|
||||
ActorID: actor.UserID,
|
||||
ActorRole: actor.Role,
|
||||
ActorName: actor.Name,
|
||||
Changes: AuditChanges(current, candidate),
|
||||
FromGeneration: current.Generation,
|
||||
ToGeneration: candidate.Generation,
|
||||
TargetGeneration: generation,
|
||||
@ -771,10 +776,9 @@ func (m *ConfigManager) writeAudit(ctx context.Context, event ConfigAuditEvent)
|
||||
}
|
||||
|
||||
func changedFields(current, candidate RuntimeConfig, section Section) []string {
|
||||
if current.Generation == candidate.Generation && section == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{string(section)}
|
||||
var fields []string
|
||||
for _, change := range AuditChanges(current, candidate) { fields = append(fields, change.Field) }
|
||||
return fields
|
||||
}
|
||||
|
||||
func servicesForSection(section Section) []ServiceName {
|
||||
@ -1075,6 +1079,7 @@ func (m *ConfigManager) MigrateLegacyPluginSecrets(ctx context.Context, importer
|
||||
FromGeneration: current.Generation,
|
||||
ToGeneration: candidate.Generation,
|
||||
ChangedFields: []string{"plugins.legacySecret"},
|
||||
Changes: AuditChanges(current, candidate),
|
||||
Result: "success",
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
|
||||
@ -31,6 +31,8 @@ type ConfigAuditEvent struct {
|
||||
Section Section `json:"section,omitempty"`
|
||||
ActorID string `json:"actorId"`
|
||||
ActorRole string `json:"actorRole"`
|
||||
ActorName string `json:"actorName,omitempty"`
|
||||
Changes []ConfigAuditChange `json:"changes,omitempty"`
|
||||
FromGeneration uint64 `json:"fromGeneration"`
|
||||
ToGeneration uint64 `json:"toGeneration"`
|
||||
TargetGeneration uint64 `json:"targetGeneration,omitempty"`
|
||||
|
||||
@ -139,6 +139,7 @@ func WriteSSLConfig(expected, next *Config) error {
|
||||
return manager.writeAudit(context.Background(), ConfigAuditEvent{
|
||||
Operation: ConfigOperationUpdate, Section: SectionSSL, ActorID: "system", ActorRole: "system",
|
||||
FromGeneration: current.Generation, ToGeneration: candidate.Generation,
|
||||
Changes: AuditChanges(current, candidate),
|
||||
ChangedFields: []string{"tls"}, Result: "success", CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@ -369,6 +369,7 @@ func configSectionFromPath(path string) (config.Section, bool) {
|
||||
|
||||
func configActor(ctx *appcontext.Context, req *http.Request) config.AuditActor {
|
||||
actor := config.AuditActor{UserID: strconv.Itoa(ctx.UserID), Role: string(ctx.EffectiveRole()), RecentAuth: ctx.RecentAuth, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured}
|
||||
actor.Name = ctx.UserAccount
|
||||
if sessionManager := session.Instance; sessionManager != nil {
|
||||
if cookie, err := req.Cookie(sessionManager.Cookie.Name); err == nil {
|
||||
actor.SessionID = cookie.Value
|
||||
|
||||
26
docs/日志改造任务.md
Normal file
26
docs/日志改造任务.md
Normal file
@ -0,0 +1,26 @@
|
||||
# 日志存储与可视化改造
|
||||
|
||||
## 已确认需求
|
||||
|
||||
- 操作记录、配置审计、收发信记录写入独立 MySQL,邮件业务数据库不随之迁移。
|
||||
- 设置导航在插件配置后增加日志入口,支持分类、时间筛选、分页与详情。
|
||||
- 运行日志保存在 `log/运行日志`,按天写入;每周一压缩已经结束的自然周,停机后启动补做。
|
||||
- 压缩后的运行日志仍可在后台查看;仅在压缩校验通过后替换对应明文文件。
|
||||
- 配置审计展示操作者、时间、具体字段及修改前后值;秘密只显示配置状态。
|
||||
- 旧配置审计幂等迁移并保留原文件,不伪造历史缺失的变更详情。
|
||||
- 不自动删除历史记录;15 GB 是容量预算而不是容量保证。
|
||||
- 配置仍在原有五个 JSON 文件中,日志连接保存在 `config.json`,密码加密保存。
|
||||
|
||||
## 实施状态
|
||||
|
||||
- [x] 已完成,等待复审:需求确认与修改前 Git 检查点;推送认证失败,未声称远程更新。
|
||||
- [~] 进行中:MySQL 存储、连接配置及旧配置审计迁移。
|
||||
- [ ] 未开始:操作及收发信记录接入。
|
||||
- [ ] 未开始:运行日志轮转、每周压缩及只读查询。
|
||||
- [ ] 未开始:具体配置变更明细与脱敏。
|
||||
- [ ] 未开始:日志管理界面及原审计界面优化。
|
||||
- [ ] 未开始:隔离测试、最新构建部署及浏览器验收。
|
||||
|
||||
## 验收边界
|
||||
|
||||
生产 MySQL 连接必须由部署者配置,不能猜测或复用其他项目的密码。未配置时保留旧审计路径并明确显示未启用,不能把 SQLite 或内存存储称为 MySQL 已启用。已配置后的写入故障必须可观察,不能无限排队或静默丢弃。
|
||||
Loading…
Reference in New Issue
Block a user