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
270 lines
8.1 KiB
Go
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()); 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()); 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,
|
|
})
|
|
}
|
|
}
|