internal/control/auditrefusal.go
95 lines · 2520 bytes
11 symbols in this file
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}