125 lines
3.4 KiB
Go
125 lines
3.4 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||
|
|
}
|