Commit bfe9eee44c

bfe9eee44c1168a16db38b3ec7cfcff1e7ee4fb9

parent: 8352b440f4

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 01:34 UTC

audit: filter by actor, action prefix, and time

audit took only --limit, so finding one account's actions meant paging
the whole log.

--actor <user> narrows to one account; --actor - selects rows with no
actor, which are host commands and auth failures. --action is a prefix
match on the action, so "cmd repo" catches every repository command.
--since is a duration back from now (30m, 24h, 7d) or a date. The
host-local gitbayd admin audit takes the same flags and --json, since
it dispatches the registry command.

Closes #73

Layout: unified · split

cmd/gitbay/main.go +1 −1
@@ -74,7 +74,7 @@ func newRoot() *cobra.Command {
7474 initCmd(),
7575 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
7676 passOpts{server: []string{"register"}}),
77 pass("audit", "instance audit log (admins): [--limit <n>]", passOpts{server: []string{"audit"}}),
77 pass("audit", "instance audit log (admins): [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]", passOpts{server: []string{"audit"}}),
7878 group("admin", "instance administration (admins)",
7979 group("user", "accounts on this instance",
8080 pass("list", "list accounts: [--state active|pending|disabled|admin] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "user", "list"}}),
e2e/adminusers_test.go +52
@@ -399,3 +399,55 @@ func TestAdminHostAndSSHAreOneSurface(t *testing.T) {
399399 }
400400 }
401401}
402
403func TestAuditFilters(t *testing.T) {
404 inst := startInstance(t)
405 rootKey := inst.newKey(t, "root")
406 aliceKey := inst.newKey(t, "alice")
407 bobKey := inst.newKey(t, "bob")
408 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
409 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
410 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
411 for _, c := range [][]string{{aliceKey, "alice/app"}, {bobKey, "bob/app"}} {
412 if _, _, code := inst.ssh(t, c[0], "", "repo", "create", c[1]); code != 0 {
413 t.Fatalf("repo create %s failed", c[1])
414 }
415 }
416 audit := func(args ...string) string {
417 t.Helper()
418 out, errOut, code := inst.ssh(t, rootKey, "", append([]string{"audit"}, args...)...)
419 if code != 0 {
420 t.Fatalf("audit %v: exit %d %s", args, code, errOut)
421 }
422 return out
423 }
424 if out := audit("--actor", "alice"); !strings.Contains(out, "alice/app") || strings.Contains(out, "bob/app") || strings.Contains(out, "user.created") {
425 t.Fatalf("--actor alice:\n%s", out)
426 }
427 if out := audit("--actor", "-"); !strings.Contains(out, "admin user.created") || strings.Contains(out, "repo create") {
428 t.Fatalf("--actor -:\n%s", out)
429 }
430 if out := audit("--action", "'cmd repo'"); strings.Count(out, "\n") != 2 || strings.Contains(out, "user.created") {
431 t.Fatalf("--action prefix:\n%s", out)
432 }
433 if out := audit("--action", "'cmd repo'", "--limit", "1"); strings.Count(out, "\n") != 1 {
434 t.Fatalf("--limit with filter:\n%s", out)
435 }
436 if out := audit("--since", "1h"); !strings.Contains(out, "alice/app") {
437 t.Fatalf("--since 1h:\n%s", out)
438 }
439 if out := audit("--since", "2099-01-01"); strings.TrimSpace(out) != "" {
440 t.Fatalf("--since in the future returned rows:\n%s", out)
441 }
442 if _, _, code := inst.ssh(t, rootKey, "", "audit", "--since", "yesterday"); code != 2 {
443 t.Fatal("bad --since accepted")
444 }
445 if _, _, code := inst.ssh(t, rootKey, "", "audit", "--actor"); code != 2 {
446 t.Fatal("dangling flag accepted")
447 }
448 // The host-local command takes the same flags and --json.
449 if out := inst.admin(t, "admin", "audit", "--actor", "bob", "--json"); !strings.Contains(out, `"protocol_version"`) ||
450 !strings.Contains(out, "bob/app") || strings.Contains(out, "alice/app") {
451 t.Fatalf("host audit --json --actor:\n%s", out)
452 }
453}
internal/control/audit.go +53 −10
@@ -4,32 +4,55 @@ import (
44 "fmt"
55 "io"
66 "strconv"
7 "strings"
8 "time"
79
810 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
912)
1013
1114func init() {
1215 register(Command{Path: []string{"audit"},
13 Summary: "instance audit log (admins)",
14 Usage: "audit [--limit <n>]", ReadOnly: true, SSHOnly: true, Run: runAudit})
16 Summary: "instance audit log (admins)",
17 Usage: "audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]",
18 ReadOnly: true, SSHOnly: true, Run: runAudit})
1519}
1620
21const auditUsage = "usage: audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]"
22
1723func runAudit(c *Ctx, args []string) int {
1824 if !c.User.IsAdmin {
1925 return c.fail(protocol.ExitDenied, "the audit log is for instance admins")
2026 }
21 limit := 100
27 f := store.AuditFilter{Limit: 100}
2228 for i := 0; i < len(args); i++ {
23 if args[i] == "--limit" && i+1 < len(args) {
24 if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 && n <= 10000 {
25 limit = n
29 if i+1 >= len(args) {
30 return c.fail(protocol.ExitUsage, auditUsage)
31 }
32 v := args[i+1]
33 switch args[i] {
34 case "--limit":
35 n, err := strconv.Atoi(v)
36 if err != nil || n < 1 || n > 10000 {
37 return c.fail(protocol.ExitUsage, "--limit must be 1 to 10000")
38 }
39 f.Limit = n
40 case "--actor":
41 f.Actor = v
42 case "--action":
43 f.ActionPrefix = v
44 case "--since":
45 t, ok := parseSince(v, 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)")
2648 }
27 i++
28 } else {
29 return c.fail(protocol.ExitUsage, "usage: audit [--limit <n>]")
49 f.Since = t.UTC().Format("2006-01-02T15:04:05.000Z")
50 default:
51 return c.fail(protocol.ExitUsage, auditUsage)
3052 }
53 i++
3154 }
32 entries, err := c.Store.AuditEntries(limit)
55 entries, err := c.Store.AuditEntries(f)
3356 if err != nil {
3457 return c.fail(protocol.ExitFailure, "%v", err)
3558 }
@@ -43,3 +66,23 @@ func runAudit(c *Ctx, args []string) int {
4366 }
4467 })
4568}
69
70// parseSince reads --since as a duration back from now (with a d suffix
71// for days, which time.ParseDuration lacks) or as a date or RFC 3339
72// timestamp.
73func parseSince(v string, now time.Time) (time.Time, bool) {
74 if strings.HasSuffix(v, "d") {
75 if n, err := strconv.Atoi(strings.TrimSuffix(v, "d")); err == nil && n >= 0 {
76 return now.Add(-time.Duration(n) * 24 * time.Hour), true
77 }
78 }
79 if d, err := time.ParseDuration(v); err == nil && d >= 0 {
80 return now.Add(-d), true
81 }
82 for _, layout := range []string{time.RFC3339, "2006-01-02"} {
83 if t, err := time.Parse(layout, v); err == nil {
84 return t, true
85 }
86 }
87 return time.Time{}, false
88}
internal/control/audit_test.go added +28
@@ -0,0 +1,28 @@
1package control
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseSince(t *testing.T) {
9 now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
10 cases := map[string]time.Time{
11 "30m": now.Add(-30 * time.Minute),
12 "24h": now.Add(-24 * time.Hour),
13 "7d": now.Add(-7 * 24 * time.Hour),
14 "2026-08-01": time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
15 "2026-08-01T10:00:00Z": time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
16 }
17 for in, want := range cases {
18 got, ok := parseSince(in, now)
19 if !ok || !got.Equal(want) {
20 t.Errorf("parseSince(%q) = %v, %v; want %v", in, got, ok, want)
21 }
22 }
23 for _, bad := range []string{"", "yesterday", "-1h", "x d", "2026-13-01"} {
24 if _, ok := parseSince(bad, now); ok {
25 t.Errorf("parseSince(%q) accepted", bad)
26 }
27 }
28}
internal/store/audit.go +33 −5
@@ -26,11 +26,39 @@ type AuditEntry struct {
2626 CreatedAt string `json:"created_at"`
2727}
2828
29func (s *Store) AuditEntries(limit int) ([]AuditEntry, error) {
30 rows, err := s.DB.Query(`
31 SELECT a.id, COALESCE(u.username, ''), a.action, a.data_json, a.created_at
32 FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id
33 ORDER BY a.id DESC LIMIT ?`, limit)
29// AuditFilter narrows AuditEntries. Actor is a username, or "-" for rows
30// with no actor (host commands, auth failures). ActionPrefix matches the
31// start of the action. Since is an ISO timestamp in the log's own format.
32type AuditFilter struct {
33 Actor string
34 ActionPrefix string
35 Since string
36 Limit int
37}
38
39func (s *Store) AuditEntries(f AuditFilter) ([]AuditEntry, error) {
40 q := `SELECT a.id, COALESCE(u.username, ''), a.action, a.data_json, a.created_at
41 FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id WHERE 1 = 1`
42 var args []any
43 switch f.Actor {
44 case "":
45 case "-":
46 q += " AND a.actor_id IS NULL"
47 default:
48 q += " AND u.username = ?"
49 args = append(args, f.Actor)
50 }
51 if f.ActionPrefix != "" {
52 q += " AND substr(a.action, 1, length(?)) = ?"
53 args = append(args, f.ActionPrefix, f.ActionPrefix)
54 }
55 if f.Since != "" {
56 q += " AND a.created_at >= ?"
57 args = append(args, f.Since)
58 }
59 q += " ORDER BY a.id DESC LIMIT ?"
60 args = append(args, f.Limit)
61 rows, err := s.DB.Query(q, args...)
3462 if err != nil {
3563 return nil, err
3664 }