feat(audit): P3-08 — audit log UI with filters

This commit is contained in:
2026-05-05 07:49:25 +01:00
parent cb3260b89c
commit 3f36bcd0b0
9 changed files with 617 additions and 3 deletions
+5
View File
@@ -207,6 +207,9 @@ func (s *Server) routes(r chi.Router) {
// Alert list (JSON variant). Same filter shape as the UI page.
r.Get("/alerts", s.handleAPIAlerts)
// Audit log (JSON variant).
r.Get("/audit", s.handleAPIAudit)
// Notification channel test-fire. Dispatches a synthetic payload
// through a single named channel; returns JSON result.
r.Post("/notifications/{id}/test", s.handleAPINotificationTest)
@@ -317,6 +320,8 @@ func (s *Server) routes(r chi.Router) {
r.Get("/alerts", s.handleUIAlerts)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
// Audit log (read-only).
r.Get("/audit", s.handleUIAudit)
// Settings shell + Notifications sub-tab CRUD.
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
+130
View File
@@ -0,0 +1,130 @@
// ui_audit.go — Audit log read-only surfaces.
//
// Routes (wired in server.go):
//
// GET /audit → handleUIAudit (HTML)
// GET /api/audit → handleAPIAudit (JSON)
//
// Filters: user, actor, action (substring), target_kind, time-range
// preset (24h | 7d | 30d | all). Page-level live refresh is *not*
// added here — audit is append-only and operators inspect history,
// not current state.
package http
import (
"encoding/json"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type auditPage struct {
Filter store.AuditFilter
Range string // "24h" | "7d" | "30d" | "all"
Entries []store.AuditEntry
UserNames map[string]string // user_id → username for row rendering
HostNames map[string]string // host_id → name (for target_kind=host display)
Actions []string // distinct actions seen so far, for the dropdown
}
// rangeToSince converts the time-range preset to a Since cutoff. "all"
// (or unrecognised) returns the zero time, meaning "no lower bound".
func rangeToSince(r string, now time.Time) time.Time {
switch r {
case "24h", "":
return now.Add(-24 * time.Hour)
case "7d":
return now.Add(-7 * 24 * time.Hour)
case "30d":
return now.Add(-30 * 24 * time.Hour)
default:
return time.Time{}
}
}
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
q := r.URL.Query()
rng := q.Get("range")
if rng == "" {
rng = "24h"
}
f := store.AuditFilter{
UserID: q.Get("user_id"),
Actor: q.Get("actor"),
ActionLike: strings.TrimSpace(q.Get("action")),
TargetKind: q.Get("target_kind"),
Since: rangeToSince(rng, time.Now().UTC()),
Limit: 500,
}
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
slog.Error("ui audit: list", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page := auditPage{
Filter: f,
Range: rng,
Entries: entries,
UserNames: map[string]string{},
HostNames: map[string]string{},
}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
for _, ux := range users {
page.UserNames[ux.ID] = ux.Username
}
}
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
for _, h := range hosts {
page.HostNames[h.ID] = h.Name
}
}
if actions, err := s.deps.Store.DistinctAuditActions(r.Context()); err == nil {
page.Actions = actions
}
view := s.baseView(r, u)
view.Title = "Audit · restic-manager"
view.Active = "audit"
view.Page = page
if err := s.deps.UI.Render(w, "audit", view); err != nil {
slog.Error("ui audit: render", "err", err)
}
}
// handleAPIAudit is the JSON variant — same filters as the HTML page.
func (s *Server) handleAPIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if _, ok := s.requireUser(r); !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
q := r.URL.Query()
rng := q.Get("range")
if rng == "" {
rng = "24h"
}
f := store.AuditFilter{
UserID: q.Get("user_id"),
Actor: q.Get("actor"),
ActionLike: strings.TrimSpace(q.Get("action")),
TargetKind: q.Get("target_kind"),
Since: rangeToSince(rng, time.Now().UTC()),
Limit: 500,
}
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}
+132
View File
@@ -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 {
+134
View File
@@ -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])
}
}
}
+32
View File
@@ -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) {