Commit bfe9eee44c
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +1 −1
| @@ -74,7 +74,7 @@ func newRoot() *cobra.Command { | ||
| 74 | 74 | initCmd(), |
| 75 | 75 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", |
| 76 | 76 | 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"}}), | |
| 78 | 78 | group("admin", "instance administration (admins)", |
| 79 | 79 | group("user", "accounts on this instance", |
| 80 | 80 | 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) { | ||
| 399 | 399 | } |
| 400 | 400 | } |
| 401 | 401 | } |
| 402 | ||
| 403 | func 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 ( | ||
| 4 | 4 | "fmt" |
| 5 | 5 | "io" |
| 6 | 6 | "strconv" |
| 7 | "strings" | |
| 8 | "time" | |
| 7 | 9 | |
| 8 | 10 | "gitbay.org/gitbay/internal/protocol" |
| 11 | "gitbay.org/gitbay/internal/store" | |
| 9 | 12 | ) |
| 10 | 13 | |
| 11 | 14 | func init() { |
| 12 | 15 | 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}) | |
| 15 | 19 | } |
| 16 | 20 | |
| 21 | const auditUsage = "usage: audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]" | |
| 22 | ||
| 17 | 23 | func runAudit(c *Ctx, args []string) int { |
| 18 | 24 | if !c.User.IsAdmin { |
| 19 | 25 | return c.fail(protocol.ExitDenied, "the audit log is for instance admins") |
| 20 | 26 | } |
| 21 | limit := 100 | |
| 27 | f := store.AuditFilter{Limit: 100} | |
| 22 | 28 | 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)") | |
| 26 | 48 | } |
| 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) | |
| 30 | 52 | } |
| 53 | i++ | |
| 31 | 54 | } |
| 32 | entries, err := c.Store.AuditEntries(limit) | |
| 55 | entries, err := c.Store.AuditEntries(f) | |
| 33 | 56 | if err != nil { |
| 34 | 57 | return c.fail(protocol.ExitFailure, "%v", err) |
| 35 | 58 | } |
| @@ -43,3 +66,23 @@ func runAudit(c *Ctx, args []string) int { | ||
| 43 | 66 | } |
| 44 | 67 | }) |
| 45 | 68 | } |
| 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. | |
| 73 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 { | ||
| 26 | 26 | CreatedAt string `json:"created_at"` |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | func (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. | |
| 32 | type AuditFilter struct { | |
| 33 | Actor string | |
| 34 | ActionPrefix string | |
| 35 | Since string | |
| 36 | Limit int | |
| 37 | } | |
| 38 | ||
| 39 | func (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...) | |
| 34 | 62 | if err != nil { |
| 35 | 63 | return nil, err |
| 36 | 64 | } |