Raw
  1package web
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"net/http"
  7	"net/netip"
  8	"net/url"
  9	"regexp"
 10	"slices"
 11	"strconv"
 12	"strings"
 13	"time"
 14	"unicode/utf8"
 15
 16	"tug/internal/platform/auditcatalog"
 17	transporthttp "tug/internal/transport/http"
 18	"tug/internal/transport/web/text"
 19)
 20
 21const (
 22	AdminAuditPerPage    = 50
 23	adminAuditValueMax   = 32
 24	adminAuditSubjectMax = 64
 25	adminAuditDetailMax  = 6
 26	adminAuditTime       = "2006-01-02T15:04"
 27)
 28
 29var (
 30	reAuditName   = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
 31	reAuditCursor = regexp.MustCompile(`^([0-9]{1,19})\.([1-9][0-9]{0,18})$`)
 32	auditParams   = []string{"actor", "action", "subject", "address", "from", "to", "older", "newer"}
 33)
 34
 35type AdminAuditCursor struct {
 36	At time.Time
 37	ID int64
 38}
 39
 40type AdminAuditQuery struct {
 41	Actor   string
 42	Action  string
 43	Subject string
 44	Address netip.Addr
 45	From    *time.Time
 46	To      *time.Time
 47	Older   *AdminAuditCursor
 48	Newer   *AdminAuditCursor
 49}
 50
 51type AdminAuditRow struct {
 52	ID      int64
 53	At      time.Time
 54	Actor   string
 55	Action  string
 56	Subject string
 57	Address string
 58	Detail  string
 59}
 60
 61type AdminAuditPage struct {
 62	Rows      []AdminAuditRow
 63	Newer     bool
 64	Older     bool
 65	Retention time.Duration
 66}
 67
 68type adminAuditForm struct {
 69	Actor, Action, Subject, Address, From, To string
 70}
 71
 72type adminAuditRowView struct {
 73	At       string
 74	Actor    Ident
 75	Sentence text.Key
 76	Action   Ident
 77	Subject  Ident
 78	Address  Ident
 79	Detail   []Ident
 80	More     int
 81}
 82
 83type adminAuditField struct {
 84	Name, Value string
 85}
 86
 87type adminAuditPager struct {
 88	Fields []adminAuditField
 89}
 90
 91type adminAuditOption struct {
 92	Value    string
 93	Selected bool
 94}
 95
 96type adminAuditData struct {
 97	Viewer         Ident
 98	Filter         adminAuditForm
 99	Filtered       bool
100	Actions        []adminAuditOption
101	Rows           []adminAuditRowView
102	Newer, Older   *adminAuditPager
103	RetentionCount int
104	RetentionUnit  text.Key
105}
106
107func parseAuditCursor(v string) (*AdminAuditCursor, bool) {
108	m := reAuditCursor.FindStringSubmatch(v)
109	if m == nil {
110		return nil, false
111	}
112	at, err := strconv.ParseInt(m[1], 10, 64)
113	if err != nil {
114		return nil, false
115	}
116	id, err := strconv.ParseInt(m[2], 10, 64)
117	if err != nil {
118		return nil, false
119	}
120	return &AdminAuditCursor{At: time.UnixMicro(at).UTC(), ID: id}, true
121}
122
123func parseAuditTime(v string) (*time.Time, bool) {
124	t, err := time.ParseInLocation(adminAuditTime, v, time.UTC)
125	if err != nil || t.Format(adminAuditTime) != v {
126		return nil, false
127	}
128	return &t, true
129}
130
131func parseAuditQuery(values url.Values) (AdminAuditQuery, adminAuditForm, bool) {
132	var q AdminAuditQuery
133	var f adminAuditForm
134	for k, vs := range values {
135		if !slices.Contains(auditParams, k) || len(vs) != 1 {
136			return q, f, false
137		}
138	}
139	get := values.Get
140	if v := get("actor"); v != "" {
141		if v != "system" && !reAdminID.MatchString(v) && !reAuditName.MatchString(v) {
142			return q, f, false
143		}
144		q.Actor, f.Actor = v, v
145	}
146	if v := get("action"); v != "" {
147		if _, ok := auditcatalog.Lookup(v); !ok {
148			return q, f, false
149		}
150		q.Action, f.Action = v, v
151	}
152	if v := get("subject"); v != "" {
153		if !reAdminID.MatchString(v) && !reSessionID.MatchString(v) && !reAuditName.MatchString(v) {
154			return q, f, false
155		}
156		q.Subject, f.Subject = v, v
157	}
158	if v := get("address"); v != "" {
159		a, err := netip.ParseAddr(v)
160		if err != nil || a.Zone() != "" {
161			return q, f, false
162		}
163		q.Address = a.Unmap()
164		f.Address = q.Address.String()
165	}
166	for _, t := range []struct {
167		name string
168		dst  **time.Time
169		echo *string
170	}{{"from", &q.From, &f.From}, {"to", &q.To, &f.To}} {
171		if v := get(t.name); v != "" {
172			at, ok := parseAuditTime(v)
173			if !ok {
174				return q, f, false
175			}
176			*t.dst, *t.echo = at, v
177		}
178	}
179	older, newer := get("older"), get("newer")
180	if older != "" && newer != "" {
181		return q, f, false
182	}
183	for _, c := range []struct {
184		value string
185		dst   **AdminAuditCursor
186	}{{older, &q.Older}, {newer, &q.Newer}} {
187		if c.value != "" {
188			cur, ok := parseAuditCursor(c.value)
189			if !ok {
190				return q, f, false
191			}
192			*c.dst = cur
193		}
194	}
195	return q, f, true
196}
197
198func auditClip(s string) string {
199	return clipTo(s, adminAuditValueMax)
200}
201
202func clipTo(s string, max int) string {
203	s = strings.ToValidUTF8(s, "")
204	if len(s) <= max {
205		return s
206	}
207	cut := max - len("…")
208	for cut > 0 && !utf8.RuneStart(s[cut]) {
209		cut--
210	}
211	return s[:cut] + "…"
212}
213
214func auditValue(v any) string {
215	switch v := v.(type) {
216	case string:
217		return v
218	case []any:
219		out := make([]string, 0, len(v))
220		for _, e := range v {
221			out = append(out, auditValue(e))
222		}
223		return strings.Join(out, " ")
224	case nil:
225		return "null"
226	}
227	var b bytes.Buffer
228	json.NewEncoder(&b).Encode(v)
229	return strings.TrimSpace(b.String())
230}
231
232func auditDetail(raw string) []Ident {
233	dec := json.NewDecoder(strings.NewReader(raw))
234	dec.UseNumber()
235	var fields map[string]any
236	if err := dec.Decode(&fields); err != nil || fields == nil {
237		if strings.TrimSpace(raw) == "" {
238			return nil
239		}
240		return []Ident{Ident(auditClip(raw))}
241	}
242	keys := make([]string, 0, len(fields))
243	for k := range fields {
244		keys = append(keys, k)
245	}
246	slices.Sort(keys)
247	var parts []Ident
248	for _, k := range keys {
249		parts = append(parts, Ident(auditClip(k)+"="+auditClip(auditValue(fields[k]))))
250	}
251	return parts
252}
253
254func actionSentence(action string) text.Key {
255	e, ok := auditcatalog.Lookup(action)
256	if !ok {
257		return ""
258	}
259	if _, ok := text.Lookup(text.Key(e.Sentence)); !ok {
260		return ""
261	}
262	return text.Key(e.Sentence)
263}
264
265func auditCursorValue(row AdminAuditRow) string {
266	return strconv.FormatInt(row.At.UnixMicro(), 10) + "." + strconv.FormatInt(row.ID, 10)
267}
268
269func auditPager(f adminAuditForm, dir string, row AdminAuditRow) *adminAuditPager {
270	p := &adminAuditPager{}
271	for _, field := range []adminAuditField{{"actor", f.Actor}, {"action", f.Action}, {"subject", f.Subject}, {"address", f.Address}, {"from", f.From}, {"to", f.To}} {
272		if field.Value != "" {
273			p.Fields = append(p.Fields, field)
274		}
275	}
276	p.Fields = append(p.Fields, adminAuditField{dir, auditCursorValue(row)})
277	return p
278}
279
280func retentionShown(d time.Duration) (int, text.Key) {
281	switch {
282	case d%(24*time.Hour) != 0:
283		return int(d / time.Hour), text.AdminAuditUnitHours
284	case d == 24*time.Hour:
285		return 1, text.AdminAuditUnitDay
286	}
287	return int(d / (24 * time.Hour)), text.AdminAuditUnitDays
288}
289
290func auditPageData(viewer Ident, f adminAuditForm, p AdminAuditPage) adminAuditData {
291	d := adminAuditData{Viewer: viewer, Filter: f, Filtered: f != adminAuditForm{}}
292	d.RetentionCount, d.RetentionUnit = retentionShown(p.Retention)
293	for _, e := range auditcatalog.Entries() {
294		d.Actions = append(d.Actions, adminAuditOption{Value: e.Name, Selected: e.Name == f.Action})
295	}
296	rows := p.Rows[:min(len(p.Rows), AdminAuditPerPage)]
297	for _, e := range rows {
298		detail := auditDetail(e.Detail)
299		more := max(0, len(detail)-adminAuditDetailMax)
300		d.Rows = append(d.Rows, adminAuditRowView{
301			At:       e.At.UTC().Format("2006-01-02 15:04:05"),
302			Actor:    Ident(e.Actor),
303			Sentence: actionSentence(e.Action),
304			Action:   Ident(e.Action),
305			Subject:  Ident(clipTo(e.Subject, adminAuditSubjectMax)),
306			Address:  Ident(e.Address),
307			Detail:   detail[:len(detail)-more],
308			More:     more,
309		})
310	}
311	if len(rows) > 0 && p.Newer {
312		d.Newer = auditPager(f, "newer", rows[0])
313	}
314	if len(rows) > 0 && (p.Older || len(p.Rows) > len(rows)) {
315		d.Older = auditPager(f, "older", rows[len(rows)-1])
316	}
317	return d
318}
319
320func (a *admin) audit(w http.ResponseWriter, r *http.Request) {
321	p, ok := a.kit.principal(w, r)
322	if !ok {
323		return
324	}
325	values, err := transporthttp.Query(r)
326	if err != nil {
327		a.badOp(w, r)
328		return
329	}
330	q, form, ok := parseAuditQuery(values)
331	if !ok {
332		a.badOp(w, r)
333		return
334	}
335	page, err := a.b.Audit(r.Context(), q)
336	if err != nil {
337		a.failed(w, r, "audit", err)
338		return
339	}
340	a.render(w, r, http.StatusOK, "admin-audit-log", auditPageData(p.Username, form, page))
341}