// Package audit protokolliert sicherheitsrelevante Vorgänge. // // Leitgedanke: Ein Audit-Eintrag ist nur so viel wert, wie er nach einem // Vorfall noch aussagt. Deshalb werden Namen mitgeschrieben statt nur IDs, und // das Protokollieren darf die eigentliche Aktion niemals scheitern lassen – // ein kaputtes Log ist ärgerlich, eine deswegen abgebrochene Anmeldung wäre // schlimmer. package audit import ( "context" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" ) // Actions – als Konstanten, damit Filter und Schreiber nicht auseinanderlaufen. const ( ActionLogin = "auth.login" ActionLoginFailed = "auth.login_failed" ActionLogout = "auth.logout" ActionPasswordChange = "auth.password_change" ActionAppPasswordCreate = "app_password.create" ActionAppPasswordRevoke = "app_password.revoke" ActionUserCreate = "admin.user_create" ActionUserUpdate = "admin.user_update" ActionUserDelete = "admin.user_delete" ActionUserPasswordReset = "admin.user_password_reset" ActionGroupCreate = "admin.group_create" ActionGroupUpdate = "admin.group_update" ActionGroupDelete = "admin.group_delete" ActionGroupMemberAdd = "admin.group_member_add" ActionGroupMemberRemove = "admin.group_member_remove" ActionSettingsUpdate = "admin.settings_update" ActionGroupFolderCreate = "admin.group_folder_create" ActionGroupFolderDelete = "admin.group_folder_delete" ActionShareCreate = "share.create" ActionShareDelete = "share.delete" ActionShareGrant = "share.grant" ActionShareRevoke = "share.revoke" ActionFileDelete = "file.delete" ActionTrashEmpty = "file.trash_empty" ActionHardDelete = "file.hard_delete" ActionFileRestore = "file.restore" ActionGarbageCollect = "system.gc" ) // Entry ist ein Eintrag, wie ihn die Admin-Ansicht liest. type Entry struct { ID uuid.UUID `json:"id"` ActorID *uuid.UUID `json:"actor_id"` ActorName string `json:"actor_name"` Action string `json:"action"` TargetType *string `json:"target_type"` TargetID *string `json:"target_id"` TargetName *string `json:"target_name"` IP *string `json:"ip"` Detail *string `json:"detail"` CreatedAt time.Time `json:"created_at"` } // Event beschreibt einen zu protokollierenden Vorgang. type Event struct { ActorID *uuid.UUID ActorName string Action string TargetType string TargetID string TargetName string IP string Detail string } type Logger struct { db *pgxpool.Pool } func NewLogger(db *pgxpool.Pool) *Logger { return &Logger{db: db} } // Log schreibt einen Eintrag. Fehler werden geloggt, nicht zurückgegeben: der // Aufrufer soll seine Aktion nicht abbrechen, nur weil das Protokoll klemmt. // // Bewusst mit eigenem Kontext: bei einem abgebrochenen Request (Client weg, // Timeout) ist der Vorgang trotzdem passiert und gehört ins Protokoll. func (l *Logger) Log(ctx context.Context, e Event) { if l == nil || l.db == nil { return } writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() _, err := l.db.Exec(writeCtx, `INSERT INTO audit_log (actor_id, actor_name, action, target_type, target_id, target_name, ip, detail) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, e.ActorID, e.ActorName, e.Action, nullIfEmpty(e.TargetType), nullIfEmpty(e.TargetID), nullIfEmpty(e.TargetName), nullIfEmpty(e.IP), nullIfEmpty(e.Detail), ) if err != nil { log.Ctx(ctx).Error().Err(err). Str("action", e.Action). Str("actor", e.ActorName). Msg("audit: could not write entry") } } // Filter für die Admin-Ansicht. type Filter struct { Action string ActorID *uuid.UUID Limit int Offset int } const ( defaultLimit = 100 maxLimit = 500 ) // List liefert Einträge, neueste zuerst, plus die Gesamtzahl für die // Blätterfunktion. func (l *Logger) List(ctx context.Context, f Filter) ([]Entry, int, error) { limit := f.Limit if limit <= 0 { limit = defaultLimit } if limit > maxLimit { limit = maxLimit } var total int if err := l.db.QueryRow(ctx, `SELECT count(*) FROM audit_log WHERE ($1 = '' OR action = $1) AND ($2::uuid IS NULL OR actor_id = $2)`, f.Action, f.ActorID, ).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count audit entries: %w", err) } rows, err := l.db.Query(ctx, `SELECT id, actor_id, actor_name, action, target_type, target_id, target_name, ip, detail, created_at FROM audit_log WHERE ($1 = '' OR action = $1) AND ($2::uuid IS NULL OR actor_id = $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4`, f.Action, f.ActorID, limit, f.Offset) if err != nil { return nil, 0, fmt.Errorf("query audit entries: %w", err) } defer rows.Close() entries := []Entry{} for rows.Next() { var e Entry if err := rows.Scan(&e.ID, &e.ActorID, &e.ActorName, &e.Action, &e.TargetType, &e.TargetID, &e.TargetName, &e.IP, &e.Detail, &e.CreatedAt); err != nil { return nil, 0, fmt.Errorf("scan audit entry: %w", err) } entries = append(entries, e) } return entries, total, rows.Err() } // Actions listet die tatsächlich vorkommenden Aktionen – füttert das // Filter-Dropdown, damit dort nichts steht, was es im Protokoll gar nicht gibt. func (l *Logger) Actions(ctx context.Context) ([]string, error) { rows, err := l.db.Query(ctx, `SELECT DISTINCT action FROM audit_log ORDER BY action`) if err != nil { return nil, fmt.Errorf("query audit actions: %w", err) } defer rows.Close() out := []string{} for rows.Next() { var a string if err := rows.Scan(&a); err != nil { return nil, fmt.Errorf("scan audit action: %w", err) } out = append(out, a) } return out, rows.Err() } func nullIfEmpty(s string) *string { if s == "" { return nil } return &s }