Files

270 lines
8.1 KiB
Go

// ui_audit.go — Audit log read-only surfaces.
//
// Routes (wired in server.go):
//
// GET /audit → handleUIAudit (HTML)
// GET /audit.csv → handleUIAuditCSV (CSV download honouring current filters)
// 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/csv"
"encoding/json"
"fmt"
"log/slog"
stdhttp "net/http"
"net/url"
"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
// Sort + Dir reflect the *resolved* sort (after allowlist
// validation) so the template can render arrows on the active
// column.
Sort string // "ts" | "actor" | "user_id" | "action" | "target_kind"
Dir string // "asc" | "desc"
// SortHrefs is a fully-encoded /audit?…&sort=COL&dir=… for each
// sortable column. Built server-side because constructing the
// querystring inside a Go html/template <a href="…"> applies
// URL-attribute escaping to '=' (turning 'range=all' into
// 'range%3dall' on the wire), which loses every filter on click.
// CSVHref is the analogous link for the export button.
SortHrefs map[string]string
CSVHref string
}
// 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{}
}
}
// auditFilterFromQuery extracts the AuditFilter + range preset from
// the request querystring. Shared by the HTML, CSV, and JSON handlers
// so all three honour the same filter URL.
func auditFilterFromQuery(r *stdhttp.Request) (store.AuditFilter, string) {
q := r.URL.Query()
rng := q.Get("range")
if rng == "" {
rng = "24h"
}
return 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: 5000, // CSV export tolerates more rows; HTML clamps via paging later
OrderBy: q.Get("sort"),
OrderAsc: q.Get("dir") == "asc",
}, rng
}
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
f, rng := auditFilterFromQuery(r)
// HTML page caps lower than CSV — keeps the table snappy.
if f.Limit > 500 {
f.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
}
// Resolve the sort key once so the page model and the template
// see the same value the SQL just used. f.OrderBy may have been
// '' or unknown → 'ts'; the template needs the resolved one.
resolvedSort := "ts"
switch f.OrderBy {
case "actor", "user_id", "action", "target_kind":
resolvedSort = f.OrderBy
}
dir := "desc"
if f.OrderAsc {
dir = "asc"
}
// Build the per-column sort hrefs once, so the template only
// has to emit them. Each click flips dir on the active column;
// any other column starts at desc (newest-first / Z→A).
base := url.Values{}
if rng != "" {
base.Set("range", rng)
}
if f.UserID != "" {
base.Set("user_id", f.UserID)
}
if f.Actor != "" {
base.Set("actor", f.Actor)
}
if f.ActionLike != "" {
base.Set("action", f.ActionLike)
}
if f.TargetKind != "" {
base.Set("target_kind", f.TargetKind)
}
csvHref := "/audit.csv?" + base.Encode()
hrefs := make(map[string]string, 5)
for _, col := range []string{"ts", "actor", "user_id", "action", "target_kind"} {
v := url.Values{}
for k, vs := range base {
v[k] = vs
}
v.Set("sort", col)
newDir := "desc"
if col == resolvedSort && dir == "desc" {
newDir = "asc"
}
v.Set("dir", newDir)
hrefs[col] = "/audit?" + v.Encode()
}
page := auditPage{
Filter: f,
Range: rng,
Entries: entries,
UserNames: map[string]string{},
HostNames: map[string]string{},
Sort: resolvedSort,
Dir: dir,
SortHrefs: hrefs,
CSVHref: csvHref,
}
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); 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
}
f, _ := auditFilterFromQuery(r)
if f.Limit > 500 {
f.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})
}
// handleUIAuditCSV streams the filtered audit log as CSV. Auth-gated
// like the HTML page; honours the same filter querystring so an
// operator can refine the view in the browser, hit Export, and get
// exactly what's on screen (plus more rows up to the 5000 cap).
func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
f, _ := auditFilterFromQuery(r)
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// Resolve user_id → username and host_id → name once for the
// human-friendly columns.
userNames := map[string]string{}
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil {
for _, ux := range users {
userNames[ux.ID] = ux.Username
}
}
hostNames := map[string]string{}
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
for _, h := range hosts {
hostNames[h.ID] = h.Name
}
}
stamp := time.Now().UTC().Format("20060102-150405")
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="audit-%s.csv"`, stamp))
cw := csv.NewWriter(w)
defer cw.Flush()
// user_id and target_id are internal ULIDs that carry no meaning
// to anyone reading the CSV — the resolved name (or — for system
// rows / non-host targets) is what an operator wants. The HTML
// page still shows IDs in the Target column for traceability when
// no name is available; the CSV is for human reporting only.
_ = cw.Write([]string{"timestamp_utc", "actor", "user", "action", "target_kind", "target_name", "payload"})
for _, e := range entries {
var uname string
if e.UserID != nil {
uname = userNames[*e.UserID]
}
var tk, tname string
if e.TargetKind != nil {
tk = *e.TargetKind
}
if tk == "host" && e.TargetID != nil {
tname = hostNames[*e.TargetID]
}
payload := ""
if len(e.Payload) > 0 {
payload = string(e.Payload)
}
_ = cw.Write([]string{
e.TS.UTC().Format("2006-01-02 15:04:05"),
e.Actor, uname, e.Action, tk, tname, payload,
})
}
}