internal/control/audit.go
86 lines · 2671 bytes
1package control
2
3import (
4 "io"
5 "strconv"
6 "strings"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 register(Command{Path: []string{"audit"},
15 Summary: "instance audit log (admins)",
16 Usage: "audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]",
17 Flags: []Flag{
18 {"--actor", "<user>|-", "only entries by this user", ""},
19 {"--action", "<prefix>", "only actions starting with this", ""},
20 {"--since", "<duration|date>", "only entries after this", ""},
21 {"--limit", "<n>", "rows to show", "100"},
22 },
23 Examples: []string{"audit --actor alice --since 24h"},
24 ReadOnly: true, Run: runAudit})
25}
26
27func runAudit(c *Ctx, args []string) int {
28 if !c.User.IsAdmin {
29 return c.fail(protocol.ExitDenied, "the audit log is for instance admins; ask one")
30 }
31 f := store.AuditFilter{Limit: 100}
32 fl, err := parseFlags(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: c.Cmd.Usage})
33 if err != nil {
34 return c.fail(protocol.ExitUsage, "%v", err)
35 }
36 if fl.Has("--limit") {
37 n, err := strconv.Atoi(fl.Value("--limit"))
38 if err != nil || n < 1 || n > 10000 {
39 return c.fail(protocol.ExitUsage, "--limit must be 1 to 10000")
40 }
41 f.Limit = n
42 }
43 f.Actor, f.ActionPrefix = fl.Value("--actor"), fl.Value("--action")
44 if fl.Has("--since") {
45 t, ok := parseSince(fl.Value("--since"), time.Now())
46 if !ok {
47 return c.fail(protocol.ExitUsage, "--since takes a duration (30m, 24h, 7d) or a date (2026-09-01, RFC 3339)")
48 }
49 f.Since = t.UTC().Format("2006-01-02T15:04:05.000Z")
50 }
51 entries, err := c.Store.AuditEntries(f)
52 if err != nil {
53 return c.fail(protocol.ExitFailure, "%v", err)
54 }
55 return c.emit(entries, func(w io.Writer) {
56 tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA")
57 for _, e := range entries {
58 actor := e.Actor
59 if actor == "" {
60 actor = "-"
61 }
62 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data))
63 }
64 tb.flush()
65 })
66}
67
68// parseSince reads --since as a duration back from now (with a d suffix
69// for days, which time.ParseDuration lacks) or as a date or RFC 3339
70// timestamp.
71func parseSince(v string, now time.Time) (time.Time, bool) {
72 if strings.HasSuffix(v, "d") {
73 if n, err := strconv.Atoi(strings.TrimSuffix(v, "d")); err == nil && n >= 0 {
74 return now.Add(-time.Duration(n) * 24 * time.Hour), true
75 }
76 }
77 if d, err := time.ParseDuration(v); err == nil && d >= 0 {
78 return now.Add(-d), true
79 }
80 for _, layout := range []string{time.RFC3339, "2006-01-02"} {
81 if t, err := time.Parse(layout, v); err == nil {
82 return t, true
83 }
84 }
85 return time.Time{}, false
86}