ba425c9766
CI / Build (windows/amd64) (pull_request) Successful in 23s
CI / Lint (pull_request) Successful in 34s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 21s
CI / Test (linux/amd64) (pull_request) Successful in 3m41s
199 lines
5.6 KiB
Go
199 lines
5.6 KiB
Go
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
|
|
// OrderBy is one of "ts" | "actor" | "user_id" | "action" |
|
|
// "target_kind". Empty / unknown falls back to "ts". The
|
|
// allowlist is enforced inside ListAudit so callers can't
|
|
// inject SQL via this field.
|
|
OrderBy string
|
|
OrderAsc bool // false = DESC (default — newest first)
|
|
}
|
|
|
|
// auditOrderColumn validates f.OrderBy against the column allowlist
|
|
// and returns the SQL fragment. Unknown / empty → "ts" so callers
|
|
// always get a deterministic order.
|
|
func auditOrderColumn(s string) string {
|
|
switch s {
|
|
case "actor", "user_id", "action", "target_kind":
|
|
return s
|
|
default:
|
|
return "ts"
|
|
}
|
|
}
|
|
|
|
// 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 ")
|
|
}
|
|
col := auditOrderColumn(f.OrderBy)
|
|
dir := "DESC"
|
|
if f.OrderAsc {
|
|
dir = "ASC"
|
|
}
|
|
// Always tie-break on ts DESC so equal sort keys (e.g. dozens
|
|
// of rows with action='alert.resolve') still come back in a
|
|
// deterministic, time-meaningful order.
|
|
if col == "ts" {
|
|
q += fmt.Sprintf(" ORDER BY ts %s", dir)
|
|
} else {
|
|
q += fmt.Sprintf(" ORDER BY %s %s, ts DESC", col, dir)
|
|
}
|
|
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
|
|
}
|