Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisabledLogWritesNothing(t *testing.T) {
|
||||
l := New("")
|
||||
if l.Enabled() {
|
||||
t.Fatal("Enabled = true for empty path")
|
||||
}
|
||||
l.Record(Entry{User: "admin", Action: "login"})
|
||||
}
|
||||
|
||||
func TestRecordAppendsJSONLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "audit.log")
|
||||
l := New(path)
|
||||
if !l.Enabled() {
|
||||
t.Fatal("Enabled = false for a real path")
|
||||
}
|
||||
l.Record(Entry{User: "admin", Action: "post.created", Resource: "hello", IP: "127.0.0.1"})
|
||||
l.Record(Entry{User: "admin", Action: "post.deleted", Detail: map[string]any{"slug": "hello"}})
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("got %d lines, want 2: %q", len(lines), data)
|
||||
}
|
||||
var first map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||
t.Fatalf("line is not JSON: %v", err)
|
||||
}
|
||||
if first["user"] != "admin" || first["action"] != "post.created" ||
|
||||
first["resource"] != "hello" || first["ip"] != "127.0.0.1" {
|
||||
t.Fatalf("first entry = %v", first)
|
||||
}
|
||||
if _, ok := first["detail"]; ok {
|
||||
t.Fatalf("unexpected detail in first entry: %v", first)
|
||||
}
|
||||
var second map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
t.Fatalf("line is not JSON: %v", err)
|
||||
}
|
||||
detail, ok := second["detail"].(map[string]any)
|
||||
if !ok || detail["slug"] != "hello" {
|
||||
t.Fatalf("second entry detail = %v", second)
|
||||
}
|
||||
ts, _ := first["ts"].(string)
|
||||
if len(ts) < 19 || !strings.Contains(ts, "T") {
|
||||
t.Fatalf("ts = %q, want ISO timestamp", ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSurvivesUnwritablePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l := New(filepath.Join(dir, "file-as-dir", "x", "audit.log"))
|
||||
if err := os.WriteFile(filepath.Join(dir, "file-as-dir"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
l.Record(Entry{User: "admin", Action: "login"}) // must not panic
|
||||
}
|
||||
|
||||
// The handler opens the file once and appends to it, and Close releases
|
||||
// the handle.
|
||||
func TestFileIsOpenedOnceAndClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.log")
|
||||
l := New(path)
|
||||
for range 50 {
|
||||
l.Record(Entry{User: "admin", Action: "post.updated", Resource: "hello"})
|
||||
}
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if got := strings.Count(string(data), "\n"); got != 50 {
|
||||
t.Fatalf("lines = %d, want 50", got)
|
||||
}
|
||||
// A write after Close reopens it rather than losing the line.
|
||||
l.Record(Entry{User: "admin", Action: "post.deleted"})
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if got := strings.Count(string(data), "\n"); got != 51 {
|
||||
t.Fatalf("lines = %d, want 51", got)
|
||||
}
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unwritable destination is reported once and never fails the caller.
|
||||
func TestUnwritableDestinationIsNotFatal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.log")
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
l := New(path)
|
||||
l.Record(Entry{User: "admin", Action: "post.created"})
|
||||
l.Record(Entry{User: "admin", Action: "post.created"})
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A transient open failure must not silence the log for the life of the
|
||||
// process: once the obstruction is gone, the next record writes.
|
||||
func TestRecordRecoversAfterThePathBecomesWritable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A regular file where the log's parent directory should be makes
|
||||
// MkdirAll fail.
|
||||
blocker := filepath.Join(dir, "blocked")
|
||||
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write blocker: %v", err)
|
||||
}
|
||||
log := New(filepath.Join(blocker, "sub", "audit.log"))
|
||||
log.Record(Entry{User: "u", Action: "first"})
|
||||
if err := log.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
if err := os.Remove(blocker); err != nil {
|
||||
t.Fatalf("remove blocker: %v", err)
|
||||
}
|
||||
log.Record(Entry{User: "u", Action: "second"})
|
||||
if err := log.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "blocked", "sub", "audit.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("the audit log never recovered: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"action":"second"`) {
|
||||
t.Fatalf("recovered log = %s", raw)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user