feat(audit): P3-08 — audit log UI with filters
This commit is contained in:
@@ -2,11 +2,143 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
func newAuditTestStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
uid := ulid.Make().String()
|
||||
if err := st.CreateUser(context.Background(), User{
|
||||
ID: uid, Username: "alice", PasswordHash: "x",
|
||||
Role: RoleOperator, CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return st, uid
|
||||
}
|
||||
|
||||
func appendAudit(t *testing.T, st *Store, uid, actor, action, targetKind, targetID string, ts time.Time) {
|
||||
t.Helper()
|
||||
var u, tk, ti *string
|
||||
if uid != "" {
|
||||
u = &uid
|
||||
}
|
||||
if targetKind != "" {
|
||||
tk = &targetKind
|
||||
}
|
||||
if targetID != "" {
|
||||
ti = &targetID
|
||||
}
|
||||
if err := st.AppendAudit(context.Background(), AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: u, Actor: actor, Action: action,
|
||||
TargetKind: tk, TargetID: ti, TS: ts, Payload: json.RawMessage(`{}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditFiltersAndOrdering(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, uid := newAuditTestStore(t)
|
||||
t0 := time.Now().UTC()
|
||||
|
||||
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h1", t0.Add(-3*time.Hour))
|
||||
appendAudit(t, st, uid, "user", "alert.acknowledge", "alert", "a1", t0.Add(-2*time.Hour))
|
||||
appendAudit(t, st, uid, "user", "alert.resolve", "alert", "a1", t0.Add(-time.Hour))
|
||||
appendAudit(t, st, "", "system", "host.auto_init", "host", "h1", t0.Add(-30*time.Minute))
|
||||
|
||||
all, err := st.ListAudit(context.Background(), AuditFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Fatalf("len: got %d want 4", len(all))
|
||||
}
|
||||
// Ordered ts DESC — most recent first.
|
||||
if all[0].Action != "host.auto_init" || all[3].Action != "host.enrolled" {
|
||||
t.Errorf("ordering: got %s ... %s", all[0].Action, all[3].Action)
|
||||
}
|
||||
|
||||
// Action prefix filter: alert.* → 2 rows.
|
||||
got, err := st.ListAudit(context.Background(), AuditFilter{ActionLike: "alert."})
|
||||
if err != nil {
|
||||
t.Fatalf("filter alert.: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("alert.* filter: got %d want 2", len(got))
|
||||
}
|
||||
|
||||
// User filter excludes system rows.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{UserID: uid})
|
||||
if len(got) != 3 {
|
||||
t.Errorf("user filter: got %d want 3", len(got))
|
||||
}
|
||||
|
||||
// Actor=system isolates the auto_init.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{Actor: "system"})
|
||||
if len(got) != 1 || got[0].Action != "host.auto_init" {
|
||||
t.Errorf("actor=system: got %+v", got)
|
||||
}
|
||||
|
||||
// Target kind filter.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{TargetKind: "alert"})
|
||||
if len(got) != 2 {
|
||||
t.Errorf("target_kind=alert: got %d want 2", len(got))
|
||||
}
|
||||
|
||||
// Time range: last 90m → resolve + auto_init.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{Since: t0.Add(-90 * time.Minute)})
|
||||
if len(got) != 2 {
|
||||
t.Errorf("since 90m: got %d want 2", len(got))
|
||||
}
|
||||
|
||||
// Limit clamps result count.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Errorf("limit: got %d want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctAuditActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, uid := newAuditTestStore(t)
|
||||
t0 := time.Now().UTC()
|
||||
|
||||
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h1", t0)
|
||||
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h2", t0)
|
||||
appendAudit(t, st, uid, "user", "alert.acknowledge", "alert", "a1", t0)
|
||||
|
||||
got, err := st.DistinctAuditActions(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("distinct: %v", err)
|
||||
}
|
||||
want := []string{"alert.acknowledge", "host.enrolled"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d]: got %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,38 @@ func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// ListUsers returns every user, sorted by username. Used by surfaces
|
||||
// that need to render a user-id → username map (audit log filter,
|
||||
// "ack'd by" projections).
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, created_at, last_login_at
|
||||
FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var role string
|
||||
var lastLogin sql.NullString
|
||||
var created string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user row: %w", err)
|
||||
}
|
||||
u.Role = Role(role)
|
||||
t, _ := time.Parse(time.RFC3339Nano, created)
|
||||
u.CreatedAt = t
|
||||
if lastLogin.Valid {
|
||||
t, _ := time.Parse(time.RFC3339Nano, lastLogin.String)
|
||||
u.LastLoginAt = &t
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountUsers returns the total number of user rows. The first-run
|
||||
// bootstrap uses this to detect a fresh install.
|
||||
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||
|
||||
Reference in New Issue
Block a user