// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package audit is a structured audit log for administrative actions. // // It writes JSON lines to a configurable file so operators can track who // did what, when, and from which IP address. The lines are produced by a // slog handler rather than assembled by hand: the handler owns the // encoding and the escaping, and a key-renaming step keeps the field // names this log has always used. package audit import ( "context" "log/slog" "os" "path/filepath" "sync" "time" ) // Entry is one audit record. Only User and Action are always present. type Entry struct { User string Action string Resource string Detail map[string]any IP string } // Log is an append-only JSON-lines audit log. An empty path disables it. type Log struct { path string mu sync.Mutex file *os.File logger *slog.Logger } // New creates a log writing to path; an empty path disables auditing. func New(path string) *Log { return &Log{path: path} } // Enabled reports whether entries are persisted. func (l *Log) Enabled() bool { return l != nil && l.path != "" } // Record appends one audit entry. Failures are logged, never raised: a // line that cannot be written must not fail the action it records. func (l *Log) Record(e Entry) { if !l.Enabled() { return } logger, err := l.handler() if err != nil { slog.Warn("audit: cannot open the log", "path", l.path, "error", err) return } attrs := make([]slog.Attr, 0, 4) if e.Resource != "" { attrs = append(attrs, slog.String("resource", e.Resource)) } if len(e.Detail) > 0 { attrs = append(attrs, slog.Any("detail", e.Detail)) } if e.IP != "" { attrs = append(attrs, slog.String("ip", e.IP)) } logger.LogAttrs(context.Background(), slog.LevelInfo, e.Action, append([]slog.Attr{slog.String("user", e.User)}, attrs...)...) } // Close releases the file handle. func (l *Log) Close() error { l.mu.Lock() defer l.mu.Unlock() if l.file == nil { return nil } err := l.file.Close() l.file = nil l.logger = nil return err } // handler returns the logger backed by the audit file, opening it on // first use. A failed open is retried on the next record rather than // cached: a transient failure (a missing parent directory, descriptor // exhaustion) must not silence the audit log for the life of the // process, and records are rare enough that the retry costs nothing. func (l *Log) handler() (*slog.Logger, error) { l.mu.Lock() defer l.mu.Unlock() if l.logger != nil { return l.logger, nil } if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { return nil, err } file, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return nil, err } l.file = file l.logger = slog.New(slog.NewJSONHandler(file, &slog.HandlerOptions{ // The field names are the log's contract with the operator's // tooling: a timestamp under "ts", the action as the message, // and no level, which an audit line does not have. ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr { switch attr.Key { case slog.TimeKey: return slog.String("ts", attr.Value.Time().UTC().Format(time.RFC3339)) case slog.MessageKey: attr.Key = "action" case slog.LevelKey: return slog.Attr{} } return attr }, })) return l.logger, nil }