472ba6a087
- users.list_page_size (Default 25), PATCH /api/auth/preferences, Whitelist 25/50/100/200, Wert in login/me-Response - Settings-UI mit Select, /search nutzt gespeicherte Seitengröße - /api/search page_size serverseitig auf max. 500 gecappt fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool (Backend kompilierte nicht) feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler, cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
329 lines
9.6 KiB
Go
329 lines
9.6 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const (
|
|
EventLogin = "login"
|
|
EventLogout = "logout"
|
|
EventSearch = "search"
|
|
EventMailView = "mail_view"
|
|
EventImport = "import"
|
|
EventExport = "export"
|
|
EventUserMgmt = "user_mgmt"
|
|
EventOCRDownload = "mail:ocr_download" // PROJ-44: extracted OCR text downloaded
|
|
EventDSGVORequest = "dsgvo_request" // PROJ-50: DSGVO Löschersuchen erfasst/bearbeitet
|
|
)
|
|
|
|
// Entry is a single audit log record.
|
|
type Entry struct {
|
|
ID int64 `json:"id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
EventType string `json:"event_type"`
|
|
Username string `json:"username"`
|
|
IPAddress string `json:"ip_address"`
|
|
Query string `json:"query"`
|
|
MailID string `json:"mail_id"`
|
|
Success bool `json:"success"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
// QueryFilter specifies filtering options for audit log queries.
|
|
type QueryFilter struct {
|
|
Username string
|
|
EventType string
|
|
MailID string
|
|
From *time.Time
|
|
To *time.Time
|
|
PageSize int
|
|
Page int
|
|
}
|
|
|
|
// 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.
|
|
// 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)
|
|
}
|
|
|
|
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(),
|
|
event_type VARCHAR(50) NOT NULL,
|
|
username VARCHAR(255) NOT NULL DEFAULT '',
|
|
ip_address VARCHAR(45) NOT NULL DEFAULT '',
|
|
query TEXT NOT NULL DEFAULT '',
|
|
mail_id VARCHAR(255) NOT NULL DEFAULT '',
|
|
success BOOLEAN NOT NULL DEFAULT true,
|
|
detail TEXT NOT NULL DEFAULT ''
|
|
);
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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.
|
|
func (l *Logger) Log(entry Entry) {
|
|
ts := entry.Timestamp
|
|
if ts.IsZero() {
|
|
ts = time.Now().UTC()
|
|
}
|
|
ctx := context.Background()
|
|
_, err := l.pool.Exec(ctx,
|
|
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, query, mail_id, success, detail)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
ts.UTC(),
|
|
entry.EventType,
|
|
entry.Username,
|
|
entry.IPAddress,
|
|
entry.Query,
|
|
entry.MailID,
|
|
entry.Success,
|
|
entry.Detail,
|
|
)
|
|
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
|
|
// matched entries, the total count (ignoring pagination), and any error.
|
|
func (l *Logger) Query(filter QueryFilter) ([]Entry, int, error) {
|
|
pageSize := filter.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = 50
|
|
}
|
|
|
|
where, args := buildWhere(filter)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Count total
|
|
countSQL := "SELECT COUNT(*) FROM audit_log" + where
|
|
var total int
|
|
if err := l.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
|
return nil, 0, fmt.Errorf("audit: count: %w", err)
|
|
}
|
|
|
|
offset := filter.Page * pageSize
|
|
// Append limit and offset as next positional args
|
|
limitArg := len(args) + 1
|
|
offsetArg := len(args) + 2
|
|
querySQL := fmt.Sprintf(
|
|
"SELECT id, timestamp, event_type, username, ip_address, query, mail_id, success, detail FROM audit_log%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d",
|
|
where, limitArg, offsetArg,
|
|
)
|
|
allArgs := append(args, pageSize, offset)
|
|
|
|
rows, err := l.pool.Query(ctx, querySQL, allArgs...)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("audit: query: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []Entry
|
|
for rows.Next() {
|
|
var e Entry
|
|
if err := rows.Scan(&e.ID, &e.Timestamp, &e.EventType, &e.Username, &e.IPAddress, &e.Query, &e.MailID, &e.Success, &e.Detail); err != nil {
|
|
return nil, 0, fmt.Errorf("audit: scan: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, total, rows.Err()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// buildWhere constructs a SQL WHERE clause from QueryFilter fields using
|
|
// positional parameters ($1, $2, ...) for PostgreSQL.
|
|
func buildWhere(f QueryFilter) (string, []interface{}) {
|
|
var clauses []string
|
|
var args []interface{}
|
|
n := 1
|
|
|
|
if f.Username != "" {
|
|
clauses = append(clauses, fmt.Sprintf("username = $%d", n))
|
|
args = append(args, f.Username)
|
|
n++
|
|
}
|
|
if f.EventType != "" {
|
|
clauses = append(clauses, fmt.Sprintf("event_type = $%d", n))
|
|
args = append(args, f.EventType)
|
|
n++
|
|
}
|
|
if f.MailID != "" {
|
|
clauses = append(clauses, fmt.Sprintf("mail_id = $%d", n))
|
|
args = append(args, f.MailID)
|
|
n++
|
|
}
|
|
if f.From != nil {
|
|
clauses = append(clauses, fmt.Sprintf("timestamp >= $%d", n))
|
|
args = append(args, f.From.UTC())
|
|
n++
|
|
}
|
|
if f.To != nil {
|
|
clauses = append(clauses, fmt.Sprintf("timestamp <= $%d", n))
|
|
args = append(args, f.To.UTC())
|
|
n++
|
|
}
|
|
|
|
if len(clauses) == 0 {
|
|
return "", args
|
|
}
|
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
|
}
|