Smail/backend/config/audit_store.go
smail 5faadcd6eb
Some checks are pending
Security Checks / security (push) Waiting to run
保存三数据库日志与三十天清理实现,继续补充验收
2026-09-08 22:30:50 +08:00

410 lines
11 KiB
Go

package config
import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
)
const ConfigAuditRetention = logpolicy.Retention
var (
ErrAuditStorageUnavailable = errors.New("configuration audit storage unavailable")
ErrAuditEventInvalid = errors.New("configuration audit event is invalid")
)
type ConfigAuditQuery struct {
ID string
From time.Time
To time.Time
ActorID string
Operation ConfigOperation
Section Section
Limit int
}
type FileAuditWriter struct {
path string
mu sync.Mutex
}
func NewFileAuditWriter(root string) (*FileAuditWriter, error) {
if strings.TrimSpace(root) == "" {
return nil, ErrAuditStorageUnavailable
}
name := "data"
if runtimepath.DeploymentLayout(root) {
name = "log"
}
directory, err := runtimepath.SecureDirectory(root, name)
if err != nil {
return nil, err
}
path := filepath.Join(directory, "config-audit-v2.jsonl")
if info, statErr := os.Lstat(path); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, ErrAuditStorageUnavailable
}
} else if !os.IsNotExist(statErr) {
return nil, statErr
}
return &FileAuditWriter{path: path}, nil
}
func (w *FileAuditWriter) WriteConfigAudit(ctx context.Context, event ConfigAuditEvent) error {
if err := auditContextError(ctx); err != nil {
return err
}
if w == nil || w.path == "" {
return ErrAuditStorageUnavailable
}
event, err := normalizeAuditEvent(event)
if err != nil {
return err
}
data, err := json.Marshal(event)
if err != nil {
return ErrAuditStorageUnavailable
}
w.mu.Lock()
defer w.mu.Unlock()
if err := ensureAuditFile(w.path); err != nil {
return err
}
file, err := os.OpenFile(w.path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
if err != nil {
return ErrAuditStorageUnavailable
}
defer file.Close()
if _, err := file.Write(append(data, '\n')); err != nil {
return ErrAuditStorageUnavailable
}
if err := file.Sync(); err != nil {
return ErrAuditStorageUnavailable
}
return nil
}
func (w *FileAuditWriter) Query(ctx context.Context, query ConfigAuditQuery) ([]ConfigAuditEvent, error) {
if err := auditContextError(ctx); err != nil {
return nil, err
}
if w == nil || w.path == "" {
return nil, ErrAuditStorageUnavailable
}
w.mu.Lock()
defer w.mu.Unlock()
if info, err := os.Lstat(w.path); err != nil {
if os.IsNotExist(err) {
return []ConfigAuditEvent{}, nil
}
return nil, ErrAuditStorageUnavailable
} else if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, ErrAuditStorageUnavailable
}
file, err := os.Open(w.path)
if err != nil {
return nil, ErrAuditStorageUnavailable
}
defer file.Close()
limit := query.Limit
if limit <= 0 || limit > 1000 {
limit = 1000
}
result := make([]ConfigAuditEvent, 0, minAuditLimit(limit, 32))
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 4096), 256*1024)
lineNumber := 0
for scanner.Scan() {
if err := auditContextError(ctx); err != nil {
return nil, err
}
lineNumber++
var event ConfigAuditEvent
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return nil, ErrAuditStorageUnavailable
}
if event.ID == "" {
event.ID = legacyAuditEventID(scanner.Bytes(), lineNumber)
}
if err := validateAuditEvent(event); err != nil {
return nil, ErrAuditStorageUnavailable
}
if !auditEventMatches(event, query) {
continue
}
result = append(result, event)
if len(result) >= limit {
break
}
}
if err := scanner.Err(); err != nil {
return nil, ErrAuditStorageUnavailable
}
return result, nil
}
func (w *FileAuditWriter) ExportJSON(ctx context.Context, query ConfigAuditQuery) ([]byte, error) {
events, err := w.Query(ctx, query)
if err != nil {
return nil, err
}
return json.Marshal(events)
}
func (w *FileAuditWriter) ExportCSV(ctx context.Context, query ConfigAuditQuery) ([]byte, error) {
events, err := w.Query(ctx, query)
if err != nil {
return nil, err
}
var builder strings.Builder
writer := csv.NewWriter(&builder)
if err := writer.Write([]string{"id", "operation", "section", "actor_id", "actor_role", "from_generation", "to_generation", "target_generation", "changed_fields", "confirmation_method", "result", "created_at"}); err != nil {
return nil, err
}
for _, event := range events {
if err := writer.Write([]string{
event.ID, string(event.Operation), string(event.Section), event.ActorID, event.ActorRole,
fmt.Sprintf("%d", event.FromGeneration), fmt.Sprintf("%d", event.ToGeneration),
fmt.Sprintf("%d", event.TargetGeneration), strings.Join(event.ChangedFields, ","),
event.ConfirmationMethod, event.Result, event.CreatedAt.UTC().Format(time.RFC3339Nano),
}); err != nil {
return nil, err
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return []byte(builder.String()), nil
}
func (w *FileAuditWriter) Cleanup(ctx context.Context, now time.Time) (int, error) {
if err := auditContextError(ctx); err != nil {
return 0, err
}
if w == nil || w.path == "" {
return 0, ErrAuditStorageUnavailable
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
w.mu.Lock()
defer w.mu.Unlock()
file, err := os.Open(w.path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, ErrAuditStorageUnavailable
}
defer file.Close()
cutoff := now.Add(-ConfigAuditRetention)
kept := make([][]byte, 0, 64)
deleted := 0
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 4096), 256*1024)
lineNumber := 0
for scanner.Scan() {
if err := auditContextError(ctx); err != nil {
return deleted, err
}
lineNumber++
var event ConfigAuditEvent
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return deleted, ErrAuditStorageUnavailable
}
if event.ID == "" {
event.ID = legacyAuditEventID(scanner.Bytes(), lineNumber)
}
if err := validateAuditEvent(event); err != nil {
return deleted, ErrAuditStorageUnavailable
}
if event.CreatedAt.Before(cutoff) {
deleted++
continue
}
data, marshalErr := json.Marshal(event)
if marshalErr != nil {
return deleted, ErrAuditStorageUnavailable
}
kept = append(kept, data)
}
if err := scanner.Err(); err != nil {
return deleted, ErrAuditStorageUnavailable
}
if err := file.Close(); err != nil {
return deleted, ErrAuditStorageUnavailable
}
if err := auditContextError(ctx); err != nil {
return deleted, err
}
if deleted == 0 {
return 0, nil
}
data := make([]byte, 0, len(kept)*128)
for _, line := range kept {
data = append(data, line...)
data = append(data, '\n')
}
if err := atomicfile.Write(w.path, data, 0600); err != nil {
return deleted, ErrAuditStorageUnavailable
}
return deleted, nil
}
func ensureAuditFile(path string) error {
if info, err := os.Lstat(path); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return ErrAuditStorageUnavailable
}
if err := os.Chmod(path, 0600); err != nil {
return ErrAuditStorageUnavailable
}
return nil
} else if !os.IsNotExist(err) {
return ErrAuditStorageUnavailable
}
return nil
}
func normalizeAuditEvent(event ConfigAuditEvent) (ConfigAuditEvent, error) {
if event.ID == "" {
id, err := newAuditEventID()
if err != nil {
return ConfigAuditEvent{}, err
}
event.ID = id
}
event.ActorID = strings.TrimSpace(event.ActorID)
event.ActorRole = strings.TrimSpace(event.ActorRole)
event.ConfirmationMethod = strings.ToLower(strings.TrimSpace(event.ConfirmationMethod))
event.ChangedFields = append([]string(nil), event.ChangedFields...)
event.Changes = append([]ConfigAuditChange(nil), event.Changes...)
if err := validateAuditEvent(event); err != nil {
return ConfigAuditEvent{}, err
}
return event, nil
}
func newAuditEventID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", ErrAuditEventInvalid
}
return "audit-" + hex.EncodeToString(value), nil
}
func legacyAuditEventID(raw []byte, lineNumber int) string {
hash := sha256.New()
hash.Write(raw)
hash.Write([]byte(fmt.Sprintf(":%d", lineNumber)))
return "legacy-" + hex.EncodeToString(hash.Sum(nil))
}
func validateAuditEvent(event ConfigAuditEvent) error {
if event.Operation != ConfigOperationUpdate && event.Operation != ConfigOperationRollback && event.Operation != ConfigOperationMigration {
return ErrAuditEventInvalid
}
if !validAuditEventID(event.ID) || strings.TrimSpace(event.ActorID) == "" || strings.TrimSpace(event.ActorRole) == "" || event.FromGeneration == 0 || event.ToGeneration == 0 || event.CreatedAt.IsZero() {
return ErrAuditEventInvalid
}
if event.Result != "success" && event.Result != "failure" {
return ErrAuditEventInvalid
}
if strings.ContainsAny(event.ActorID+event.ActorRole+event.ActorName+event.ConfirmationMethod, "\r\n") || len(event.ActorID) > 128 || len(event.ActorRole) > 64 || len(event.ActorName) > 256 {
return ErrAuditEventInvalid
}
for _, field := range event.ChangedFields {
if field == "" || len(field) > 1024 || strings.ContainsAny(field, "\r\n") {
return ErrAuditEventInvalid
}
}
for _, change := range event.Changes {
if change.Field == "" || len(change.Field) > 1024 || strings.ContainsAny(change.Field, "\r\n") || len([]rune(change.Before)) > 513 || len([]rune(change.After)) > 513 {
return ErrAuditEventInvalid
}
}
return nil
}
// NormalizeConfigAuditEvent 供数据库审计复用同一套标识、校验和复制规则。
func NormalizeConfigAuditEvent(event ConfigAuditEvent) (ConfigAuditEvent, error) {
return normalizeAuditEvent(event)
}
func auditEventMatches(event ConfigAuditEvent, query ConfigAuditQuery) bool {
if query.ID != "" && event.ID != query.ID {
return false
}
if !query.From.IsZero() && event.CreatedAt.Before(query.From) {
return false
}
if !query.To.IsZero() && event.CreatedAt.After(query.To) {
return false
}
if query.ActorID != "" && event.ActorID != query.ActorID {
return false
}
if query.Operation != "" && event.Operation != query.Operation {
return false
}
if query.Section != "" && event.Section != query.Section {
return false
}
return true
}
func validAuditEventID(value string) bool {
if len(value) != 38 && len(value) != 71 {
return false
}
if strings.HasPrefix(value, "audit-") {
value = strings.TrimPrefix(value, "audit-")
} else if strings.HasPrefix(value, "legacy-") {
value = strings.TrimPrefix(value, "legacy-")
} else {
return false
}
for _, character := range value {
if !(character >= '0' && character <= '9') && !(character >= 'a' && character <= 'f') {
return false
}
}
return true
}
func auditContextError(ctx context.Context) error {
if ctx == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
func minAuditLimit(left, right int) int {
if left < right {
return left
}
return right
}