internal/control/audit.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/control/audit.go history · blame · raw

86 lines · 2672 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 := c.parseArgs(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}