feat(PROJ-48): Audit-Log Unveränderbarkeit (Trigger, append-only Logfile, Healthcheck)

DB-Trigger audit_log_immutable verhindert UPDATE/DELETE auf audit_log,
zusätzliches append-only JSON-Lines-Logfile (audit.log_path) als
tamper-evident Backup, neuer Healthcheck-Prüfpunkt in archivmail status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-13 19:44:07 +02:00
parent cca27c663a
commit 7e4175923f
7 changed files with 325 additions and 12 deletions
+140 -10
View File
@@ -2,9 +2,13 @@ package audit
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -45,22 +49,56 @@ type QueryFilter struct {
Page int
}
// Logger is a PostgreSQL-backed, append-only audit log.
// Logger is a PostgreSQL-backed, append-only audit log. In addition to the
// database, every event is mirrored to a tamper-evident JSON-Lines file
// (PROJ-48) which is opened in append-only mode and never truncated.
type Logger struct {
pool *pgxpool.Pool
logger *slog.Logger
fileMu sync.Mutex
file *os.File // nil if file logging is unavailable
logPath string
}
// fileEntry is the JSON-Lines representation written to the audit log file.
// Field names and the RFC 3339 (UTC) timestamp match the PROJ-11 spec.
type fileEntry struct {
Timestamp string `json:"timestamp"`
EventType string `json:"event_type"`
Username string `json:"username"`
IPAddress string `json:"ip_address"`
Query string `json:"query,omitempty"`
MailID string `json:"mail_id,omitempty"`
Success bool `json:"success"`
Detail string `json:"detail,omitempty"`
}
// New connects to PostgreSQL using the given DSN and initialises the schema.
// logDir is reserved for future flat-file logging.
func New(dsn, logDir string, logger *slog.Logger) (*Logger, error) {
// logPath is the destination of the append-only JSON-Lines audit file
// (PROJ-48). If it is empty, file logging is disabled. If the file cannot be
// opened, a warning is logged and the service continues with DB-only logging.
func New(dsn, logPath string, logger *slog.Logger) (*Logger, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("audit: connect: %w", err)
}
_, err = pool.Exec(ctx, `
if err := initSchema(ctx, pool); err != nil {
pool.Close()
return nil, fmt.Errorf("audit: create schema: %w", err)
}
l := &Logger{pool: pool, logger: logger, logPath: logPath}
l.openLogFile()
return l, nil
}
// initSchema creates the audit_log table and installs the immutability trigger
// (PROJ-48). Both operations are idempotent and safe on existing databases.
func initSchema(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -72,13 +110,65 @@ func New(dsn, logDir string, logger *slog.Logger) (*Logger, error) {
success BOOLEAN NOT NULL DEFAULT true,
detail TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
pool.Close()
return nil, fmt.Errorf("audit: create schema: %w", err)
`); err != nil {
return err
}
return &Logger{pool: pool, logger: logger}, nil
// PROJ-48: make audit_log append-only at the database level. A BEFORE
// UPDATE OR DELETE trigger raises an exception for every row mutation,
// regardless of the DB role used by the application. This is the strongest
// available GoBD guarantee — even a compromised application process or a
// direct DB connection through the normal application role cannot alter or
// remove existing entries. A future, documented anonymisation path
// (PROJ-20/DSGVO) would require a dedicated maintenance role and is
// intentionally not granted any exception here yet.
if _, err := pool.Exec(ctx, `
CREATE OR REPLACE FUNCTION audit_log_no_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only: % is not permitted', TG_OP
USING ERRCODE = 'integrity_constraint_violation';
END;
$$ LANGUAGE plpgsql;
`); err != nil {
return fmt.Errorf("create trigger function: %w", err)
}
// DROP + CREATE keeps the trigger definition idempotent on re-init.
if _, err := pool.Exec(ctx, `
DROP TRIGGER IF EXISTS audit_log_immutable ON audit_log;
`); err != nil {
return fmt.Errorf("drop trigger: %w", err)
}
if _, err := pool.Exec(ctx, `
CREATE TRIGGER audit_log_immutable
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_no_mutation();
`); err != nil {
return fmt.Errorf("create trigger: %w", err)
}
return nil
}
// openLogFile opens the append-only audit log file. Failures are non-fatal:
// the service continues with DB-only logging and emits a warning.
func (l *Logger) openLogFile() {
if l.logPath == "" {
l.logger.Warn("audit: log_path not configured, file logging disabled")
return
}
if dir := filepath.Dir(l.logPath); dir != "" && dir != "." {
// Best effort — if the dir already exists this is a no-op.
_ = os.MkdirAll(dir, 0o750)
}
f, err := os.OpenFile(l.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
if err != nil {
l.logger.Warn("audit: audit log file not writable, continuing with DB-only logging",
"path", l.logPath, "err", err)
return
}
l.file = f
}
// Log appends an entry to the audit log. Errors are logged but not returned.
@@ -103,6 +193,39 @@ func (l *Logger) Log(entry Entry) {
if err != nil {
l.logger.Error("audit: insert failed", "err", err)
}
l.writeFile(entry, ts.UTC())
}
// writeFile appends one JSON-Lines record to the audit log file. A file-write
// failure (e.g. disk full, file removed by rotation) must never block the DB
// log path, so errors are only logged. The mutex serialises concurrent writers;
// combined with O_APPEND each line is written atomically.
func (l *Logger) writeFile(entry Entry, ts time.Time) {
l.fileMu.Lock()
defer l.fileMu.Unlock()
if l.file == nil {
return
}
line, err := json.Marshal(fileEntry{
Timestamp: ts.Format(time.RFC3339),
EventType: entry.EventType,
Username: entry.Username,
IPAddress: entry.IPAddress,
Query: entry.Query,
MailID: entry.MailID,
Success: entry.Success,
Detail: entry.Detail,
})
if err != nil {
l.logger.Error("audit: marshal log line failed", "err", err)
return
}
if _, err := l.file.Write(append(line, '\n')); err != nil {
l.logger.Error("audit: write to log file failed", "path", l.logPath, "err", err)
}
}
// Query retrieves audit entries matching the given filter, returning the
@@ -151,8 +274,15 @@ func (l *Logger) Query(filter QueryFilter) ([]Entry, int, error) {
return entries, total, rows.Err()
}
// Close closes the audit connection pool.
// Close closes the audit log file and the connection pool.
func (l *Logger) Close() error {
l.fileMu.Lock()
if l.file != nil {
_ = l.file.Sync()
_ = l.file.Close()
l.file = nil
}
l.fileMu.Unlock()
l.pool.Close()
return nil
}
+133
View File
@@ -1,9 +1,12 @@
package audit_test
import (
"bufio"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -13,6 +16,39 @@ import (
"archivmail/internal/audit"
)
// testDSN returns a schema-scoped DSN for an isolated audit_log table, or skips
// the test if no PostgreSQL is configured.
func testDSN(t *testing.T) (dsn, schemaDSN string) {
t.Helper()
dsn = os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
}
schema := "autest_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
if len(schema) > 63 {
schema = schema[:63]
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema)
conn.Close(ctx)
t.Cleanup(func() {
conn2, _ := pgx.Connect(context.Background(), dsn)
if conn2 != nil {
conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
conn2.Close(context.Background())
}
})
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
return dsn, dsn + sep + "search_path=" + schema
}
func newTestAudit(t *testing.T) *audit.Logger {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
@@ -177,3 +213,100 @@ func TestQueryPagination(t *testing.T) {
t.Errorf("page 2 len = %d, want 2", len(page2))
}
}
// TestImmutableTrigger verifies the PROJ-48 DB trigger rejects UPDATE and
// DELETE on audit_log through the normal application connection.
func TestImmutableTrigger(t *testing.T) {
dsn, schemaDSN := testDSN(t)
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
l, err := audit.New(schemaDSN, t.TempDir(), logger)
if err != nil {
t.Fatalf("audit.New: %v", err)
}
defer l.Close()
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
// Connect through the same search_path to hit the protected table.
_ = dsn
ctx := context.Background()
conn, err := pgx.Connect(ctx, schemaDSN)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, "UPDATE audit_log SET detail = 'tampered'"); err == nil {
t.Error("UPDATE on audit_log should fail but succeeded")
}
if _, err := conn.Exec(ctx, "DELETE FROM audit_log"); err == nil {
t.Error("DELETE on audit_log should fail but succeeded")
}
// INSERT must still work (verified indirectly via Query count).
_, total, _ := l.Query(audit.QueryFilter{PageSize: 50})
if total != 1 {
t.Errorf("expected 1 surviving entry, got %d", total)
}
}
// TestFileLogging verifies that each event is mirrored to the append-only
// JSON-Lines file (PROJ-48).
func TestFileLogging(t *testing.T) {
_, schemaDSN := testDSN(t)
logPath := filepath.Join(t.TempDir(), "audit.log")
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
l, err := audit.New(schemaDSN, logPath, logger)
if err != nil {
t.Fatalf("audit.New: %v", err)
}
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", IPAddress: "10.0.0.1", Success: true})
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "bob", Query: "invoice", Success: false, Detail: "denied"})
l.Close()
f, err := os.Open(logPath)
if err != nil {
t.Fatalf("open log file: %v", err)
}
defer f.Close()
var lines []map[string]any
sc := bufio.NewScanner(f)
for sc.Scan() {
var m map[string]any
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
t.Fatalf("invalid JSON line %q: %v", sc.Text(), err)
}
lines = append(lines, m)
}
if len(lines) != 2 {
t.Fatalf("expected 2 log lines, got %d", len(lines))
}
if lines[0]["username"] != "alice" || lines[0]["event_type"] != "login" {
t.Errorf("line 0 unexpected: %v", lines[0])
}
if _, err := time.Parse(time.RFC3339, lines[0]["timestamp"].(string)); err != nil {
t.Errorf("timestamp not RFC3339: %v", lines[0]["timestamp"])
}
}
// TestFileLoggingUnwritableContinues verifies the service tolerates an
// unwritable log path (DB-only logging continues, no panic).
func TestFileLoggingUnwritableContinues(t *testing.T) {
_, schemaDSN := testDSN(t)
// A directory cannot be opened for writing → file logging disabled.
logPath := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
l, err := audit.New(schemaDSN, logPath, logger)
if err != nil {
t.Fatalf("audit.New should not fail on unwritable path: %v", err)
}
defer l.Close()
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
_, total, _ := l.Query(audit.QueryFilter{PageSize: 50})
if total != 1 {
t.Errorf("DB logging must continue, got total=%d", total)
}
}