diff --git a/cmd/archivmail/cmd_status.go b/cmd/archivmail/cmd_status.go index 79d30e2..ba21539 100644 --- a/cmd/archivmail/cmd_status.go +++ b/cmd/archivmail/cmd_status.go @@ -45,6 +45,7 @@ func runStatus(args []string) { checkPostgres(cfg), checkManticore(cfg), checkStorage(cfg), + checkAuditLog(cfg), } allOK := true @@ -172,6 +173,28 @@ func checkStorage(cfg *config.Config) checkResult { return checkResult{Name: "Storage", OK: ok, Detail: detail} } +// checkAuditLog verifies that the append-only audit log file (PROJ-48) exists +// and is writable. It performs a non-destructive O_APPEND open without writing +// any bytes, so the immutability of existing content is not affected. +func checkAuditLog(cfg *config.Config) checkResult { + path := cfg.Audit.ResolvedLogPath() + + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o640) + if err != nil { + if os.IsNotExist(err) { + return checkResult{Name: "Audit-Log", OK: false, Detail: fmt.Sprintf("%s existiert nicht", path)} + } + return checkResult{Name: "Audit-Log", OK: false, Detail: fmt.Sprintf("%s nicht beschreibbar: %v", path, err)} + } + defer f.Close() + + detail := path + ", beschreibbar" + if fi, statErr := f.Stat(); statErr == nil { + detail = fmt.Sprintf("%s, beschreibbar, %s", path, formatBytes(uint64(fi.Size()))) + } + return checkResult{Name: "Audit-Log", OK: true, Detail: detail} +} + func formatBytes(b uint64) string { const unit = 1024 if b < unit { diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 65f7a7c..455ed9c 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -248,7 +248,7 @@ func main() { defer users.Close() // Audit log - audlog, err := audit.New(cfg.Database.DSN(), cfg.Audit.LogPath, logger) + audlog, err := audit.New(cfg.Database.DSN(), cfg.Audit.ResolvedLogPath(), logger) if err != nil { logger.Error("audit init failed", "err", err) os.Exit(1) diff --git a/config.test.yml b/config.test.yml index a5a0386..6214e35 100644 --- a/config.test.yml +++ b/config.test.yml @@ -44,3 +44,9 @@ index: logging: path: /tmp/archivmail-test/logs level: debug + +audit: + # Append-only JSON-Lines Audit-Logdatei (PROJ-48). + # Default falls leer: /var/log/archivmail/audit.log + log_path: /tmp/archivmail-test/audit.log + retention_days: 0 diff --git a/config/config.go b/config/config.go index 48e1f3f..b0f00a9 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) @@ -116,12 +117,25 @@ type IndexConfig struct { ManticoreDSN string `yaml:"manticore_dsn"` // DSN for Manticore backend (default: "manticore@tcp(127.0.0.1:9306)/") } +// DefaultAuditLogPath is the default location of the append-only JSON-Lines +// audit log file (PROJ-48) when audit.log_path is not configured. +const DefaultAuditLogPath = "/var/log/archivmail/audit.log" + // AuditConfig holds audit log settings. type AuditConfig struct { LogPath string `yaml:"log_path"` RetentionDays int `yaml:"retention_days"` } +// ResolvedLogPath returns the configured audit log file path, falling back to +// DefaultAuditLogPath when unset. +func (a AuditConfig) ResolvedLogPath() string { + if strings.TrimSpace(a.LogPath) == "" { + return DefaultAuditLogPath + } + return a.LogPath +} + // LoggingConfig holds application logging settings. type LoggingConfig struct { Path string `yaml:"path"` diff --git a/features/INDEX.md b/features/INDEX.md index ad0b3bb..bd7ca33 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -62,7 +62,14 @@ | PROJ-43 | Automatische Archivierungsregeln | Planned | [PROJ-43](PROJ-43-archivierungsregeln.md) | 2026-04-05 | | PROJ-44 | OCR-GUI-Integration (Status, Download, Such-Highlight) | Deployed | [PROJ-44](PROJ-44-ocr-gui-integration.md) | 2026-05-08 | | PROJ-45 | IMAP Per-Folder UID-Tracking + UIDVALIDITY-Check | Deployed | [PROJ-45](PROJ-45-imap-folder-uid-tracking.md) | 2026-05-11 | +| PROJ-46 | E-Mail als primärer Login-Identifier für Tenant-User | Planned | [PROJ-46](PROJ-46-email-login-tenant-user.md) | 2026-06-13 | +| PROJ-47 | Tenant-Voll-Export per CLI | In Review | [PROJ-47](PROJ-47-tenant-voll-export-cli.md) | 2026-06-13 | +| PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | In Review | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 | +| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Planned | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 | +| PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | Planned | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 | +| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | Planned | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 | +| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 | -## Next Available ID: PROJ-46 +## Next Available ID: PROJ-53 diff --git a/internal/audit/audit.go b/internal/audit/audit.go index bf1c9b4..2133530 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -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 } diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 46fe6e4..440bcac 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -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) + } +}