Commit 607ba55d8f

607ba55d8f6105b46a3a1323aee2208d2819a8fb

parent: 5a1b72b671

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 21:21 UTC

control: audit refused mutating commands, rate-limited per actor

Ref #275

Layout: unified · split

internal/control/auditrefusal.go added +95
@@ -0,0 +1,95 @@
1package control
2
3import (
4 "sync"
5 "time"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// refusalsPerMinute bounds audit rows for refused writes per actor. A
11// probe is what these rows record, and a loop of probes must not grow
12// the table without bound.
13const refusalsPerMinute = 10
14
15// refusalsPerMinuteGlobal bounds them across all actors. Registration is
16// open and pending accounts reach Dispatch, so the per-actor bound alone
17// scales with the number of accounts.
18const refusalsPerMinuteGlobal = 600
19
20type refusalLimiter struct {
21 mu sync.Mutex
22 seen map[int64]*refusalWindow
23 global refusalWindow
24}
25
26type refusalWindow struct {
27 start time.Time
28 n int
29}
30
31var refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
32
33// What to write for one refusal.
34const (
35 refusalDrop = iota
36 refusalRecord
37 refusalThrottleActor
38 refusalThrottleGlobal
39)
40
41// allow decides what to write for this refusal. Every row written,
42// throttle rows included, counts against the global bound.
43func (l *refusalLimiter) allow(actor int64, now time.Time) int {
44 l.mu.Lock()
45 defer l.mu.Unlock()
46 if len(l.seen) > 4096 {
47 for k, w := range l.seen {
48 if now.Sub(w.start) >= time.Minute {
49 delete(l.seen, k)
50 }
51 }
52 }
53 w := l.seen[actor]
54 if w == nil || now.Sub(w.start) >= time.Minute {
55 w = &refusalWindow{start: now}
56 l.seen[actor] = w
57 }
58 w.n++
59 want := refusalRecord
60 switch {
61 case w.n == refusalsPerMinute+1:
62 want = refusalThrottleActor
63 case w.n > refusalsPerMinute:
64 return refusalDrop
65 }
66 if now.Sub(l.global.start) >= time.Minute {
67 l.global = refusalWindow{start: now}
68 }
69 l.global.n++
70 switch {
71 case l.global.n <= refusalsPerMinuteGlobal:
72 return want
73 case l.global.n == refusalsPerMinuteGlobal+1:
74 return refusalThrottleGlobal
75 }
76 return refusalDrop
77}
78
79// AuditRefused records a refused attempt to change something. Past the
80// per-actor or the global limit it records one refused.throttled row a
81// minute for that scope and drops the rest. Dispatcher tests run without
82// a store.
83func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any) {
84 if st == nil {
85 return
86 }
87 switch refusals.allow(actorID, time.Now()) {
88 case refusalRecord:
89 st.Audit(actorID, action, data)
90 case refusalThrottleActor:
91 st.Audit(actorID, "refused.throttled", map[string]any{"scope": "actor", "limit_per_minute": refusalsPerMinute})
92 case refusalThrottleGlobal:
93 st.Audit(actorID, "refused.throttled", map[string]any{"scope": "global", "limit_per_minute": refusalsPerMinuteGlobal})
94 }
95}
internal/control/auditrefusal_test.go added +195
@@ -0,0 +1,195 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestRefusedWritesAreAudited(t *testing.T) {
14 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
15 st, repo, _ := newQueueTestRepo(t)
16 bobID, err := st.CreateUser("bob", false)
17 if err != nil {
18 t.Fatal(err)
19 }
20 bob := store.User{ID: bobID, Username: "bob"}
21
22 c, _ := pruneCtx(st, t.TempDir(), bob)
23 if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied {
24 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
25 }
26 // A refused read is not a write attempt.
27 c, _ = pruneCtx(st, t.TempDir(), bob)
28 if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied {
29 t.Fatalf("audit: exit %d", code)
30 }
31 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10})
32 if err != nil {
33 t.Fatal(err)
34 }
35 if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" {
36 t.Fatalf("entries: %+v", got)
37 }
38}
39
40func TestRefusalAuditIsRateLimited(t *testing.T) {
41 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
42 st, repo, _ := newQueueTestRepo(t)
43 bobID, err := st.CreateUser("bob", false)
44 if err != nil {
45 t.Fatal(err)
46 }
47 for range refusalsPerMinute + 5 {
48 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
49 Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"})
50 }
51 refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100})
52 throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100})
53 if len(refused) != refusalsPerMinute || len(throttled) != 1 {
54 t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled))
55 }
56}
57
58// The #257 refusal of a minting command under an expiring credential is
59// a refused write like any other.
60func TestExpiringMintRefusalIsAudited(t *testing.T) {
61 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
62 st, _, _ := newQueueTestRepo(t)
63 bobID, err := st.CreateUser("bob", false)
64 if err != nil {
65 t.Fatal(err)
66 }
67 exp := time.Now().Add(time.Hour)
68 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
69 c.Expires = &exp
70 if code := Dispatch(c, []string{"token", "create", "--name", "x"}); code != protocol.ExitDenied {
71 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
72 }
73 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
74 if err != nil {
75 t.Fatal(err)
76 }
77 if len(got) != 1 || got[0].Action != "refused token create" {
78 t.Fatalf("entries: %+v", got)
79 }
80}
81
82// A gate refuses before parseFlags, so the row must not keep a value
83// glued to its flag, a value that looks like a flag, or a positional
84// past the target.
85func TestRefusalRowKeepsNoValues(t *testing.T) {
86 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
87 st, repo, _ := newQueueTestRepo(t)
88 bobID, err := st.CreateUser("bob", false)
89 if err != nil {
90 t.Fatal(err)
91 }
92 for _, argv := range [][]string{
93 {"issue", "create", repo.Path(), "--body=hunter2"},
94 {"issue", "create", repo.Path(), "--title", "--body=hunter2"},
95 {"repo", "secret", "set", repo.Path(), "NAME", "hunter2"},
96 } {
97 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
98 c.ReadOnly = true
99 if code := Dispatch(c, argv); code != protocol.ExitDenied {
100 t.Fatalf("%q: exit %d, want %d", argv, code, protocol.ExitDenied)
101 }
102 }
103 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
104 if err != nil {
105 t.Fatal(err)
106 }
107 if len(got) != 3 {
108 t.Fatalf("entries: %+v", got)
109 }
110 for _, e := range got {
111 if strings.Contains(e.Data, "hunter2") || strings.Contains(e.Data, "NAME") {
112 t.Errorf("%s kept a value: %s", e.Action, e.Data)
113 }
114 if !strings.Contains(e.Data, repo.Path()) {
115 t.Errorf("%s lost its target: %s", e.Action, e.Data)
116 }
117 }
118}
119
120func TestRefusalArgs(t *testing.T) {
121 for _, tc := range []struct{ in, want []string }{
122 {[]string{"o/r", "NAME", "value"}, []string{"o/r"}},
123 {[]string{"o/r", "--body=x", "--title", "--label=y"}, []string{"o/r", "--body", "--title", "--label"}},
124 {[]string{"o/r", "--title", "t", "--", "a", "b"}, []string{"o/r", "--title"}},
125 } {
126 if got := refusalArgs(tc.in); !slices.Equal(got, tc.want) {
127 t.Errorf("refusalArgs(%q) = %q, want %q", tc.in, got, tc.want)
128 }
129 }
130}
131
132func TestRefusalLimiterWindowResets(t *testing.T) {
133 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
134 now := time.Unix(1_000_000, 0)
135 for i := range refusalsPerMinute {
136 if v := l.allow(1, now); v != refusalRecord {
137 t.Fatalf("refusal %d: %d", i, v)
138 }
139 }
140 if v := l.allow(1, now); v != refusalThrottleActor {
141 t.Fatalf("first past the limit: %d", v)
142 }
143 if v := l.allow(1, now.Add(59*time.Second)); v != refusalDrop {
144 t.Fatalf("second past the limit: %d", v)
145 }
146 if v := l.allow(2, now); v != refusalRecord {
147 t.Fatalf("another actor: %d", v)
148 }
149 if v := l.allow(1, now.Add(time.Minute)); v != refusalRecord {
150 t.Fatalf("next minute: %d", v)
151 }
152}
153
154func TestRefusalLimiterGlobalCeiling(t *testing.T) {
155 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
156 now := time.Unix(1_000_000, 0)
157 var rec, global int
158 for actor := range int64(refusalsPerMinuteGlobal/refusalsPerMinute + 10) {
159 for range refusalsPerMinute {
160 switch l.allow(actor, now) {
161 case refusalRecord:
162 rec++
163 case refusalThrottleGlobal:
164 global++
165 case refusalThrottleActor:
166 t.Fatal("actor throttled under its own limit")
167 }
168 }
169 }
170 if rec != refusalsPerMinuteGlobal || global != 1 {
171 t.Fatalf("%d recorded, %d global throttle rows", rec, global)
172 }
173 if v := l.allow(9999, now.Add(time.Minute)); v != refusalRecord {
174 t.Fatalf("next minute: %d", v)
175 }
176}
177
178func TestRefusalLimiterPrunes(t *testing.T) {
179 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
180 now := time.Unix(1_000_000, 0)
181 for actor := range int64(4097) {
182 l.seen[actor] = &refusalWindow{start: now, n: 1}
183 }
184 l.allow(5000, now.Add(time.Minute))
185 if len(l.seen) != 1 {
186 t.Fatalf("%d windows after prune, want 1", len(l.seen))
187 }
188 for actor := range int64(4097) {
189 l.seen[actor] = &refusalWindow{start: now.Add(time.Minute), n: 1}
190 }
191 l.allow(6000, now.Add(time.Minute+time.Second))
192 if len(l.seen) != 4099 {
193 t.Fatalf("%d windows, want 4099: a live window was pruned", len(l.seen))
194 }
195}
internal/control/control.go +43 −10
@@ -162,6 +162,23 @@ func Dispatch(c *Ctx, argv []string) int {
162 args = append(args, a) 162 args = append(args, a)
163 } 163 }
164 c.Argv = args 164 c.Argv = args
165 code := runChecked(c, cmd, args)
166 if !cmd.ReadOnly {
167 switch code {
168 case protocol.ExitOK:
169 // Every successful mutating command lands in the audit log.
170 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{"argv": auditArgs(args), "source": c.Source})
171 case protocol.ExitDenied, protocol.ExitNotFound:
172 // So does every refused one: probing leaves a trace.
173 AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path),
174 map[string]any{"argv": refusalArgs(args), "source": c.Source, "exit": code})
175 }
176 }
177 return code
178}
179
180// runChecked applies the dispatcher's own gates, then runs the command.
181func runChecked(c *Ctx, cmd Command, args []string) int {
165 // A runner-scoped key reaches the runner protocol and nothing else, so 182 // A runner-scoped key reaches the runner protocol and nothing else, so
166 // the key a CI host holds cannot administer the instance. 183 // the key a CI host holds cannot administer the instance.
167 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { 184 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
@@ -195,15 +212,7 @@ func Dispatch(c *Ctx, argv []string) int {
195 if !cmd.ReadsStdin { 212 if !cmd.ReadsStdin {
196 c.Stdin = emptyReader{} 213 c.Stdin = emptyReader{}
197 } 214 }
198 code := cmd.Run(c, args) 215 return cmd.Run(c, args)
199 // Every successful mutating command lands in the audit log.
200 if code == protocol.ExitOK && !cmd.ReadOnly {
201 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{
202 "argv": auditArgs(args),
203 "source": c.Source,
204 })
205 }
206 return code
207} 216}
208 217
209// auditArgs is argv with flag values dropped. Secrets never reach argv — 218// auditArgs is argv with flag values dropped. Secrets never reach argv —
@@ -220,7 +229,10 @@ func auditArgs(args []string) []string {
220 out = append(out, a) 229 out = append(out, a)
221 continue 230 continue
222 } 231 }
223 out = append(out, a) 232 // A gate refuses before parseFlags runs, so a "--name=value"
233 // token reaches here whole; only the name is kept.
234 name, _, _ := strings.Cut(a, "=")
235 out = append(out, name)
224 // "--" ends flag parsing; everything after it is positional. 236 // "--" ends flag parsing; everything after it is positional.
225 if a == "--" { 237 if a == "--" {
226 out = append(out, args[i+1:]...) 238 out = append(out, args[i+1:]...)
@@ -236,6 +248,27 @@ func auditArgs(args []string) []string {
236 return out 248 return out
237} 249}
238 250
251// refusalArgs is what a refusal row keeps of argv: the flag names and the
252// first positional, which names the target. A gate refuses before the
253// handler checks its arguments, so later positionals may be anything the
254// caller typed, a value meant for stdin included.
255func refusalArgs(args []string) []string {
256 out := []string{}
257 target := false
258 for _, a := range auditArgs(args) {
259 if a == "--" {
260 break
261 }
262 if strings.HasPrefix(a, "--") {
263 out = append(out, a)
264 } else if !target {
265 out = append(out, a)
266 target = true
267 }
268 }
269 return out
270}
271
239// pendingAllowed lists what an unverified self-registered account may do. 272// pendingAllowed lists what an unverified self-registered account may do.
240// limitWrites spends one token of the account's write budget, and refuses 273// limitWrites spends one token of the account's write budget, and refuses
241// with the wait when it is empty. Returns -1 when the command may run. 274// with the wait when it is empty. Returns -1 when the command may run.