internal/control/auditrefusal.go

v1.40.0
gitbay/internal/control/auditrefusal.go history · blame · raw

95 lines · 2520 bytes

 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}