package web import ( "bytes" "encoding/json" "net/http" "net/netip" "net/url" "regexp" "slices" "strconv" "strings" "time" "unicode/utf8" "tug/internal/platform/auditcatalog" transporthttp "tug/internal/transport/http" "tug/internal/transport/web/text" ) const ( AdminAuditPerPage = 50 adminAuditValueMax = 32 adminAuditSubjectMax = 64 adminAuditDetailMax = 6 adminAuditTime = "2006-01-02T15:04" ) var ( reAuditName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) reAuditCursor = regexp.MustCompile(`^([0-9]{1,19})\.([1-9][0-9]{0,18})$`) auditParams = []string{"actor", "action", "subject", "address", "from", "to", "older", "newer"} ) type AdminAuditCursor struct { At time.Time ID int64 } type AdminAuditQuery struct { Actor string Action string Subject string Address netip.Addr From *time.Time To *time.Time Older *AdminAuditCursor Newer *AdminAuditCursor } type AdminAuditRow struct { ID int64 At time.Time Actor string Action string Subject string Address string Detail string } type AdminAuditPage struct { Rows []AdminAuditRow Newer bool Older bool Retention time.Duration } type adminAuditForm struct { Actor, Action, Subject, Address, From, To string } type adminAuditRowView struct { At string Actor Ident Sentence text.Key Action Ident Subject Ident Address Ident Detail []Ident More int } type adminAuditField struct { Name, Value string } type adminAuditPager struct { Fields []adminAuditField } type adminAuditOption struct { Value string Selected bool } type adminAuditData struct { Viewer Ident Filter adminAuditForm Filtered bool Actions []adminAuditOption Rows []adminAuditRowView Newer, Older *adminAuditPager RetentionCount int RetentionUnit text.Key } func parseAuditCursor(v string) (*AdminAuditCursor, bool) { m := reAuditCursor.FindStringSubmatch(v) if m == nil { return nil, false } at, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return nil, false } id, err := strconv.ParseInt(m[2], 10, 64) if err != nil { return nil, false } return &AdminAuditCursor{At: time.UnixMicro(at).UTC(), ID: id}, true } func parseAuditTime(v string) (*time.Time, bool) { t, err := time.ParseInLocation(adminAuditTime, v, time.UTC) if err != nil || t.Format(adminAuditTime) != v { return nil, false } return &t, true } func parseAuditQuery(values url.Values) (AdminAuditQuery, adminAuditForm, bool) { var q AdminAuditQuery var f adminAuditForm for k, vs := range values { if !slices.Contains(auditParams, k) || len(vs) != 1 { return q, f, false } } get := values.Get if v := get("actor"); v != "" { if v != "system" && !reAdminID.MatchString(v) && !reAuditName.MatchString(v) { return q, f, false } q.Actor, f.Actor = v, v } if v := get("action"); v != "" { if _, ok := auditcatalog.Lookup(v); !ok { return q, f, false } q.Action, f.Action = v, v } if v := get("subject"); v != "" { if !reAdminID.MatchString(v) && !reSessionID.MatchString(v) && !reAuditName.MatchString(v) { return q, f, false } q.Subject, f.Subject = v, v } if v := get("address"); v != "" { a, err := netip.ParseAddr(v) if err != nil || a.Zone() != "" { return q, f, false } q.Address = a.Unmap() f.Address = q.Address.String() } for _, t := range []struct { name string dst **time.Time echo *string }{{"from", &q.From, &f.From}, {"to", &q.To, &f.To}} { if v := get(t.name); v != "" { at, ok := parseAuditTime(v) if !ok { return q, f, false } *t.dst, *t.echo = at, v } } older, newer := get("older"), get("newer") if older != "" && newer != "" { return q, f, false } for _, c := range []struct { value string dst **AdminAuditCursor }{{older, &q.Older}, {newer, &q.Newer}} { if c.value != "" { cur, ok := parseAuditCursor(c.value) if !ok { return q, f, false } *c.dst = cur } } return q, f, true } func auditClip(s string) string { return clipTo(s, adminAuditValueMax) } func clipTo(s string, max int) string { s = strings.ToValidUTF8(s, "") if len(s) <= max { return s } cut := max - len("…") for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } return s[:cut] + "…" } func auditValue(v any) string { switch v := v.(type) { case string: return v case []any: out := make([]string, 0, len(v)) for _, e := range v { out = append(out, auditValue(e)) } return strings.Join(out, " ") case nil: return "null" } var b bytes.Buffer json.NewEncoder(&b).Encode(v) return strings.TrimSpace(b.String()) } func auditDetail(raw string) []Ident { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() var fields map[string]any if err := dec.Decode(&fields); err != nil || fields == nil { if strings.TrimSpace(raw) == "" { return nil } return []Ident{Ident(auditClip(raw))} } keys := make([]string, 0, len(fields)) for k := range fields { keys = append(keys, k) } slices.Sort(keys) var parts []Ident for _, k := range keys { parts = append(parts, Ident(auditClip(k)+"="+auditClip(auditValue(fields[k])))) } return parts } func actionSentence(action string) text.Key { e, ok := auditcatalog.Lookup(action) if !ok { return "" } if _, ok := text.Lookup(text.Key(e.Sentence)); !ok { return "" } return text.Key(e.Sentence) } func auditCursorValue(row AdminAuditRow) string { return strconv.FormatInt(row.At.UnixMicro(), 10) + "." + strconv.FormatInt(row.ID, 10) } func auditPager(f adminAuditForm, dir string, row AdminAuditRow) *adminAuditPager { p := &adminAuditPager{} for _, field := range []adminAuditField{{"actor", f.Actor}, {"action", f.Action}, {"subject", f.Subject}, {"address", f.Address}, {"from", f.From}, {"to", f.To}} { if field.Value != "" { p.Fields = append(p.Fields, field) } } p.Fields = append(p.Fields, adminAuditField{dir, auditCursorValue(row)}) return p } func retentionShown(d time.Duration) (int, text.Key) { switch { case d%(24*time.Hour) != 0: return int(d / time.Hour), text.AdminAuditUnitHours case d == 24*time.Hour: return 1, text.AdminAuditUnitDay } return int(d / (24 * time.Hour)), text.AdminAuditUnitDays } func auditPageData(viewer Ident, f adminAuditForm, p AdminAuditPage) adminAuditData { d := adminAuditData{Viewer: viewer, Filter: f, Filtered: f != adminAuditForm{}} d.RetentionCount, d.RetentionUnit = retentionShown(p.Retention) for _, e := range auditcatalog.Entries() { d.Actions = append(d.Actions, adminAuditOption{Value: e.Name, Selected: e.Name == f.Action}) } rows := p.Rows[:min(len(p.Rows), AdminAuditPerPage)] for _, e := range rows { detail := auditDetail(e.Detail) more := max(0, len(detail)-adminAuditDetailMax) d.Rows = append(d.Rows, adminAuditRowView{ At: e.At.UTC().Format("2006-01-02 15:04:05"), Actor: Ident(e.Actor), Sentence: actionSentence(e.Action), Action: Ident(e.Action), Subject: Ident(clipTo(e.Subject, adminAuditSubjectMax)), Address: Ident(e.Address), Detail: detail[:len(detail)-more], More: more, }) } if len(rows) > 0 && p.Newer { d.Newer = auditPager(f, "newer", rows[0]) } if len(rows) > 0 && (p.Older || len(p.Rows) > len(rows)) { d.Older = auditPager(f, "older", rows[len(rows)-1]) } return d } func (a *admin) audit(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } values, err := transporthttp.Query(r) if err != nil { a.badOp(w, r) return } q, form, ok := parseAuditQuery(values) if !ok { a.badOp(w, r) return } page, err := a.b.Audit(r.Context(), q) if err != nil { a.failed(w, r, "audit", err) return } a.render(w, r, http.StatusOK, "admin-audit-log", auditPageData(p.Username, form, page)) }