package api_impl import ( "log/slog" "net/http" "strings" "sync" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/throttle" ) // PrincipalThrottle rate-limits authenticated requests per principal. // Read and write operations are tracked in separate buckets with independent // limits. Limits are adjustable at runtime via SetRead/SetWrite so they can be // tightened during an overload incident without a restart. type PrincipalThrottle struct { Bucket throttle.TokenBucket FailOpen bool mu sync.RWMutex read throttleConfig write throttleConfig } type throttleConfig struct { Capacity int RefillRate float64 // tokens per second } // NewPrincipalThrottle returns a PrincipalThrottle with defaults: // - Reads: capacity 10 000, refill 167/s (~10 000/min — effectively unlimited) // - Writes: capacity 60, refill 1/s (60/min with burst of 60) func NewPrincipalThrottle(bucket throttle.TokenBucket) *PrincipalThrottle { return &PrincipalThrottle{ Bucket: bucket, FailOpen: true, read: throttleConfig{Capacity: 10_000, RefillRate: 10_000.0 / 60}, write: throttleConfig{Capacity: 60, RefillRate: 1.0}, } } // SetRead updates the read limit. Safe to call concurrently. func (t *PrincipalThrottle) SetRead(capacity int, refillRate float64) { t.mu.Lock() t.read = throttleConfig{Capacity: capacity, RefillRate: refillRate} t.mu.Unlock() } // SetWrite updates the write limit. Safe to call concurrently. func (t *PrincipalThrottle) SetWrite(capacity int, refillRate float64) { t.mu.Lock() t.write = throttleConfig{Capacity: capacity, RefillRate: refillRate} t.mu.Unlock() } func (t *PrincipalThrottle) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal := iam.GetPrincipal(r.Context()) t.mu.RLock() var cfg throttleConfig if isPrincipalReadPath(r.URL.Path) { cfg = t.read } else { cfg = t.write } t.mu.RUnlock() key := principalThrottleKey(r.URL.Path, principal.Subject) allowed, err := t.Bucket.Take(r.Context(), key, cfg.Capacity, cfg.RefillRate) if err != nil { slog.ErrorContext(r.Context(), "principal throttle error", "err", err) if !t.FailOpen { w.WriteHeader(http.StatusTooManyRequests) return } } else if !allowed { w.WriteHeader(http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } // isPrincipalReadPath returns true for operations that only read data. func isPrincipalReadPath(path string) bool { return strings.Contains(path, "_Get") || strings.Contains(path, "_List") || strings.Contains(path, "_Check") } func principalThrottleKey(path, subject string) string { if isPrincipalReadPath(path) { return "principal:read:" + subject } return "principal:write:" + subject }