package store import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" ) // AuditFilter narrows ListAudit. Empty fields match anything. type AuditFilter struct { UserID string // empty matches any user OR system rows Actor string // user | agent | system | "" (any) Action string // exact match (e.g. "host.enrolled") ActionLike string // substring match (e.g. "alert." matches alert.acknowledge / alert.resolve) TargetKind string // host | source_group | alert | notification_channel | "" (any) TargetID string // exact match on target_id Since time.Time // zero = no lower bound Until time.Time // zero = no upper bound Limit int // 0 = no limit } // ListAudit returns audit_log rows ordered by ts DESC. func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, error) { q := `SELECT id, user_id, actor, action, target_kind, target_id, ts, payload FROM audit_log` conds := []string{} args := []any{} if f.UserID != "" { conds = append(conds, "user_id = ?") args = append(args, f.UserID) } if f.Actor != "" { conds = append(conds, "actor = ?") args = append(args, f.Actor) } if f.Action != "" { conds = append(conds, "action = ?") args = append(args, f.Action) } if f.ActionLike != "" { conds = append(conds, "action LIKE ?") args = append(args, "%"+f.ActionLike+"%") } if f.TargetKind != "" { conds = append(conds, "target_kind = ?") args = append(args, f.TargetKind) } if f.TargetID != "" { conds = append(conds, "target_id = ?") args = append(args, f.TargetID) } if !f.Since.IsZero() { conds = append(conds, "ts >= ?") args = append(args, f.Since.UTC().Format(time.RFC3339Nano)) } if !f.Until.IsZero() { conds = append(conds, "ts <= ?") args = append(args, f.Until.UTC().Format(time.RFC3339Nano)) } if len(conds) > 0 { q += " WHERE " + strings.Join(conds, " AND ") } q += ` ORDER BY ts DESC` if f.Limit > 0 { q += ` LIMIT ?` args = append(args, f.Limit) } rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("store: list audit: %w", err) } defer func() { _ = rows.Close() }() var out []AuditEntry for rows.Next() { e, err := scanAuditRow(rows.Scan) if err != nil { return nil, err } out = append(out, *e) } return out, rows.Err() } // DistinctAuditActions returns the set of distinct action strings // currently present in the table — used to populate the action filter // dropdown so the operator picks from what actually exists, not a // hardcoded list that might drift from the codebase. func (s *Store) DistinctAuditActions(ctx context.Context) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT action FROM audit_log ORDER BY action`) if err != nil { return nil, fmt.Errorf("store: distinct audit actions: %w", err) } defer func() { _ = rows.Close() }() var out []string for rows.Next() { var a string if err := rows.Scan(&a); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } func scanAuditRow(scan func(...any) error) (*AuditEntry, error) { var e AuditEntry var userID, targetKind, targetID, payload sql.NullString var ts string if err := scan(&e.ID, &userID, &e.Actor, &e.Action, &targetKind, &targetID, &ts, &payload); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("store: scan audit: %w", err) } if userID.Valid { v := userID.String e.UserID = &v } if targetKind.Valid { v := targetKind.String e.TargetKind = &v } if targetID.Valid { v := targetID.String e.TargetID = &v } t, err := time.Parse(time.RFC3339Nano, ts) if err != nil { return nil, fmt.Errorf("store: parse audit ts: %w", err) } e.TS = t if payload.Valid && payload.String != "" { e.Payload = json.RawMessage(payload.String) } return &e, nil } // AppendAudit records an audit log entry. func (s *Store) AppendAudit(ctx context.Context, e AuditEntry) error { if len(e.Payload) == 0 { e.Payload = json.RawMessage("{}") } _, err := s.db.ExecContext(ctx, `INSERT INTO audit_log (id, user_id, actor, action, target_kind, target_id, ts, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, nullable(e.UserID), e.Actor, e.Action, nullable(e.TargetKind), nullable(e.TargetID), e.TS.UTC().Format(time.RFC3339Nano), string(e.Payload)) if err != nil { return fmt.Errorf("store: append audit: %w", err) } return nil } // nullable returns nil for nil/empty *string so SQLite stores NULL. // SQLite's driver treats Go nil as NULL but treats *string("") as ”. // We want NULL semantics for "absent." func nullable(p *string) any { if p == nil || *p == "" { return nil } return *p }