internal/control/audit.go
135 lines · 3895 bytes
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}