internal/control/audit.go

135 lines · 3895 bytes

5 symbols in this file
  1package control
  2
  3import (
  4	"encoding/json"
  5	"io"
  6	"slices"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15func init() {
 16	register(Command{Path: []string{"audit"},
 17		Summary: "instance audit log (admins)",
 18		Usage:   "audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]",
 19		Flags: []Flag{
 20			{"--actor", "<user>|-", "only entries by this user", ""},
 21			{"--action", "<prefix>", "only actions starting with this", ""},
 22			{"--since", "<duration|date>", "only entries after this", ""},
 23			{"--limit", "<n>", "rows to show", "100"},
 24		},
 25		Examples: []string{"audit --actor alice --since 24h"},
 26		ReadOnly: true, Run: runAudit})
 27}
 28
 29func runAudit(c *Ctx, args []string) int {
 30	if !c.User.IsAdmin {
 31		return c.fail(protocol.ExitDenied, "the audit log is for instance admins; ask one")
 32	}
 33	f := store.AuditFilter{Limit: 100}
 34	fl, err := c.parseArgs(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: c.Cmd.Usage})
 35	if err != nil {
 36		return c.fail(protocol.ExitUsage, "%v", err)
 37	}
 38	if fl.Has("--limit") {
 39		n, err := strconv.Atoi(fl.Value("--limit"))
 40		if err != nil || n < 1 || n > 10000 {
 41			return c.fail(protocol.ExitUsage, "--limit must be 1 to 10000")
 42		}
 43		f.Limit = n
 44	}
 45	f.Actor, f.ActionPrefix = fl.Value("--actor"), fl.Value("--action")
 46	if fl.Has("--since") {
 47		t, ok := parseSince(fl.Value("--since"), time.Now())
 48		if !ok {
 49			return c.fail(protocol.ExitUsage, "--since takes a duration (30m, 24h, 7d) or a date (2026-09-01, RFC 3339)")
 50		}
 51		f.Since = t.UTC().Format("2006-01-02T15:04:05.000Z")
 52	}
 53	entries, err := c.Store.AuditEntries(f)
 54	if err != nil {
 55		return c.fail(protocol.ExitFailure, "%v", err)
 56	}
 57	return c.emitView(entries, func(w io.Writer) {
 58		tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA")
 59		for _, e := range entries {
 60			actor := e.Actor
 61			if actor == "" {
 62				actor = "-"
 63			}
 64			tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data))
 65		}
 66		tb.flush()
 67	}, func() screen {
 68		rows := make([]row, len(entries))
 69		for i, e := range entries {
 70			actor := e.Actor
 71			if actor == "" {
 72				actor = "-"
 73			}
 74			rows[i] = rowOf(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(keyValues(e.Data)))
 75		}
 76		return listScreen("Audit", rows, action{"Filter", []string{"audit", "--since", "24h"}})
 77	})
 78}
 79
 80// parseSince reads --since as a duration back from now (with a d suffix
 81// for days, which time.ParseDuration lacks) or as a date or RFC 3339
 82// timestamp.
 83func parseSince(v string, now time.Time) (time.Time, bool) {
 84	if strings.HasSuffix(v, "d") {
 85		if n, err := strconv.Atoi(strings.TrimSuffix(v, "d")); err == nil && n >= 0 {
 86			return now.Add(-time.Duration(n) * 24 * time.Hour), true
 87		}
 88	}
 89	if d, err := time.ParseDuration(v); err == nil && d >= 0 {
 90		return now.Add(-d), true
 91	}
 92	for _, layout := range []string{time.RFC3339, "2006-01-02"} {
 93		if t, err := time.Parse(layout, v); err == nil {
 94			return t, true
 95		}
 96	}
 97	return time.Time{}, false
 98}
 99
100// keyValues is an audit entry's JSON data as a terminal reads it:
101// key=value pairs in key order, strings bare, arrays space-separated.
102// Anything that is not a JSON object is returned as it is.
103func keyValues(data string) string {
104	var m map[string]any
105	if json.Unmarshal([]byte(data), &m) != nil {
106		return data
107	}
108	keys := make([]string, 0, len(m))
109	for k := range m {
110		keys = append(keys, k)
111	}
112	slices.Sort(keys)
113	parts := make([]string, len(keys))
114	for i, k := range keys {
115		parts[i] = k + "=" + kvValue(m[k])
116	}
117	return strings.Join(parts, " ")
118}
119
120func kvValue(v any) string {
121	switch v := v.(type) {
122	case string:
123		return v
124	case []any:
125		parts := make([]string, len(v))
126		for i, e := range v {
127			parts[i] = kvValue(e)
128		}
129		return "[" + strings.Join(parts, " ") + "]"
130	case nil:
131		return ""
132	}
133	b, _ := json.Marshal(v)
134	return string(b)
135}