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:
+140
-10
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user