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:
@@ -45,6 +45,7 @@ func runStatus(args []string) {
|
|||||||
checkPostgres(cfg),
|
checkPostgres(cfg),
|
||||||
checkManticore(cfg),
|
checkManticore(cfg),
|
||||||
checkStorage(cfg),
|
checkStorage(cfg),
|
||||||
|
checkAuditLog(cfg),
|
||||||
}
|
}
|
||||||
|
|
||||||
allOK := true
|
allOK := true
|
||||||
@@ -172,6 +173,28 @@ func checkStorage(cfg *config.Config) checkResult {
|
|||||||
return checkResult{Name: "Storage", OK: ok, Detail: detail}
|
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 {
|
func formatBytes(b uint64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
if b < unit {
|
if b < unit {
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ func main() {
|
|||||||
defer users.Close()
|
defer users.Close()
|
||||||
|
|
||||||
// Audit log
|
// 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 {
|
if err != nil {
|
||||||
logger.Error("audit init failed", "err", err)
|
logger.Error("audit init failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -44,3 +44,9 @@ index:
|
|||||||
logging:
|
logging:
|
||||||
path: /tmp/archivmail-test/logs
|
path: /tmp/archivmail-test/logs
|
||||||
level: debug
|
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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"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)/")
|
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.
|
// AuditConfig holds audit log settings.
|
||||||
type AuditConfig struct {
|
type AuditConfig struct {
|
||||||
LogPath string `yaml:"log_path"`
|
LogPath string `yaml:"log_path"`
|
||||||
RetentionDays int `yaml:"retention_days"`
|
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.
|
// LoggingConfig holds application logging settings.
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
|
|||||||
+8
-1
@@ -62,7 +62,14 @@
|
|||||||
| PROJ-43 | Automatische Archivierungsregeln | Planned | [PROJ-43](PROJ-43-archivierungsregeln.md) | 2026-04-05 |
|
| 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-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-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 |
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
## Next Available ID: PROJ-46
|
## Next Available ID: PROJ-53
|
||||||
|
|||||||
+140
-10
@@ -2,9 +2,13 @@ package audit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -45,22 +49,56 @@ type QueryFilter struct {
|
|||||||
Page int
|
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 {
|
type Logger struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
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.
|
// New connects to PostgreSQL using the given DSN and initialises the schema.
|
||||||
// logDir is reserved for future flat-file logging.
|
// logPath is the destination of the append-only JSON-Lines audit file
|
||||||
func New(dsn, logDir string, logger *slog.Logger) (*Logger, error) {
|
// (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()
|
ctx := context.Background()
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("audit: connect: %w", err)
|
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 (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
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,
|
success BOOLEAN NOT NULL DEFAULT true,
|
||||||
detail TEXT NOT NULL DEFAULT ''
|
detail TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
`)
|
`); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
pool.Close()
|
|
||||||
return nil, fmt.Errorf("audit: create schema: %w", 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.
|
// 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 {
|
if err != nil {
|
||||||
l.logger.Error("audit: insert failed", "err", err)
|
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
|
// 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()
|
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 {
|
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()
|
l.pool.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package audit_test
|
package audit_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,6 +16,39 @@ import (
|
|||||||
"archivmail/internal/audit"
|
"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 {
|
func newTestAudit(t *testing.T) *audit.Logger {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user