Skip to main content

solid_pod_rs/security/
rate_limit.rs

1//! Pluggable rate-limit primitive (Sprint 7 §6.1, ADR-057).
2//!
3//! The library exposes a transport-agnostic [`RateLimiter`] trait plus a
4//! reference in-process [`LruRateLimiter`] implementation. Consumer
5//! binders (actix-web, axum, tower) adapt the trait to their middleware
6//! surface — this crate never mounts routes itself (F7 boundary).
7//!
8//! ## Algorithm
9//!
10//! Sliding-window counter keyed by `(route, subject)`. Each bucket
11//! stores the monotonic `Instant` of every hit inside the current
12//! window. On each [`RateLimiter::check`]:
13//!
14//! 1. Prune entries older than `window`.
15//! 2. If the remaining count `>= max`, deny with
16//!    `retry_after_secs = ceil(window - (now - oldest_hit))`.
17//! 3. Otherwise, record `now` and allow.
18//!
19//! ## Storage
20//!
21//! An LRU cache bounds memory under pathological key churn. The cache
22//! capacity defaults to `DEFAULT_LRU_CAPACITY` (4096). Entries that are
23//! evicted lose their history — a deliberate trade-off: the bound is
24//! hard, and real-world adversaries cannot force forgiveness of their
25//! own recent hits without also flushing their own bucket.
26//!
27//! ## Subject identity
28//!
29//! [`RateLimitSubject`] distinguishes per-IP (anonymous requests) from
30//! per-WebID (authenticated requests). Consumers SHOULD prefer WebID
31//! keying for authenticated endpoints: it is stable across NAT churn.
32//!
33//! ## Concurrency
34//!
35//! The limiter uses `parking_lot::Mutex` (already in the dep graph via
36//! `reqwest`). Contention is O(1) per check; the critical section is
37//! the prune-and-push on a single bucket.
38
39#[cfg(feature = "rate-limit")]
40use std::time::Duration;
41
42use async_trait::async_trait;
43
44/// Rate-limit subject — the entity whose quota is being counted.
45///
46/// Variants deliberately borrow `&str` / `&IpAddr` rather than owning;
47/// the limiter computes a canonical string key at check time and drops
48/// the borrow.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RateLimitSubject<'a> {
51    /// Anonymous client keyed by source IP.
52    Ip(std::net::IpAddr),
53    /// Authenticated client keyed by WebID URL.
54    WebId(&'a str),
55    /// Opaque caller-supplied key (e.g. API-key fingerprint).
56    Custom(&'a str),
57}
58
59#[cfg(feature = "rate-limit")]
60impl RateLimitSubject<'_> {
61    /// Canonical string representation, used as the bucket key.
62    fn canonical(&self) -> String {
63        match self {
64            RateLimitSubject::Ip(ip) => format!("ip:{ip}"),
65            RateLimitSubject::WebId(w) => format!("webid:{w}"),
66            RateLimitSubject::Custom(c) => format!("custom:{c}"),
67        }
68    }
69}
70
71/// Composite key for a limiter bucket. Bundles the logical route name
72/// with the subject; buckets never cross routes.
73#[derive(Debug, Clone)]
74pub struct RateLimitKey<'a> {
75    /// Logical route name (e.g. `pod_create`, `write`, `idp_credentials`).
76    pub route: &'a str,
77    /// Subject identity.
78    pub subject: RateLimitSubject<'a>,
79}
80
81#[cfg(feature = "rate-limit")]
82impl RateLimitKey<'_> {
83    fn canonical(&self) -> String {
84        format!("{}|{}", self.route, self.subject.canonical())
85    }
86}
87
88/// Outcome of a single [`RateLimiter::check`].
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum RateLimitDecision {
91    /// Request is permitted. The hit was recorded.
92    Allow,
93    /// Request exceeds the configured quota for this key. Caller
94    /// SHOULD return `429 Too Many Requests` with a `Retry-After`
95    /// header equal to `retry_after_secs`.
96    Deny {
97        /// Seconds the client should wait before retrying.
98        retry_after_secs: u64,
99        /// Configured maximum for this route.
100        limit: u32,
101        /// Configured window in seconds for this route.
102        window_secs: u64,
103    },
104}
105
106/// Transport-agnostic rate-limit contract.
107///
108/// Implementations MUST be `Send + Sync + 'static` so consumer binders
109/// can wrap them in a shared `Arc<dyn RateLimiter>` inside their
110/// middleware stack.
111#[async_trait]
112pub trait RateLimiter: Send + Sync + 'static {
113    /// Check and record a single hit for `key`. Returns
114    /// [`RateLimitDecision::Allow`] if the request is permitted (and
115    /// records the hit), or [`RateLimitDecision::Deny`] otherwise
116    /// (without recording).
117    async fn check(&self, key: &RateLimitKey<'_>) -> RateLimitDecision;
118}
119
120// -- reference implementation ---------------------------------------------
121
122#[cfg(feature = "rate-limit")]
123mod lru_impl {
124    use super::*;
125
126    use std::num::NonZeroUsize;
127    use std::time::Instant;
128
129    use lru::LruCache;
130    use parking_lot::Mutex;
131
132    /// Default LRU capacity. Bounds memory under key churn.
133    pub const DEFAULT_LRU_CAPACITY: usize = 4096;
134
135    /// Default policy when no per-route policy is supplied: 60 hits
136    /// per 60 s. Deliberately generous — binders SHOULD configure
137    /// tighter policies per route.
138    const DEFAULT_MAX: u32 = 60;
139    const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
140
141    /// Sliding-window bucket: the hit timestamps inside the current
142    /// window. Stored oldest-first so prune + retry-after are O(window).
143    #[derive(Debug, Default)]
144    struct SlidingWindow {
145        hits: Vec<Instant>,
146    }
147
148    impl SlidingWindow {
149        fn prune(&mut self, now: Instant, window: Duration) {
150            let cutoff = now.checked_sub(window);
151            match cutoff {
152                Some(c) => self.hits.retain(|t| *t > c),
153                None => self.hits.clear(),
154            }
155        }
156    }
157
158    /// LRU-cached sliding-window rate limiter.
159    pub struct LruRateLimiter {
160        buckets: Mutex<LruCache<String, SlidingWindow>>,
161        policies: Vec<RoutePolicy>,
162        default_policy: RoutePolicy,
163    }
164
165    #[derive(Debug, Clone)]
166    struct RoutePolicy {
167        route: String,
168        max: u32,
169        window: Duration,
170    }
171
172    impl LruRateLimiter {
173        /// Construct a limiter with no per-route policies and the
174        /// default fall-back (60 hits / 60 s).
175        pub fn new() -> Self {
176            Self::with_capacity_and_policies(DEFAULT_LRU_CAPACITY, Vec::new())
177        }
178
179        /// Construct a limiter with explicit per-route policies.
180        /// Routes not present in `policies` fall back to the default
181        /// (60 hits / 60 s).
182        ///
183        /// Panics if any `max` is zero or any `window` is zero — a zero
184        /// limit is non-sensical (would deny every request) and a zero
185        /// window would divide by zero when computing retry-after.
186        pub fn with_policy(policies: Vec<(String, u32, Duration)>) -> Self {
187            Self::with_capacity_and_policies(DEFAULT_LRU_CAPACITY, policies)
188        }
189
190        /// Construct with an explicit LRU capacity.
191        pub fn with_capacity_and_policies(
192            capacity: usize,
193            policies: Vec<(String, u32, Duration)>,
194        ) -> Self {
195            // SAFETY: 1 is non-zero, so this is infallible.
196            const ONE: NonZeroUsize = match NonZeroUsize::new(1) {
197                Some(v) => v,
198                None => unreachable!(),
199            };
200            let capacity = NonZeroUsize::new(capacity.max(1)).unwrap_or(ONE);
201
202            let policies = policies
203                .into_iter()
204                .map(|(route, max, window)| {
205                    assert!(max > 0, "rate-limit max must be non-zero");
206                    assert!(!window.is_zero(), "rate-limit window must be non-zero");
207                    RoutePolicy {
208                        route,
209                        max,
210                        window,
211                    }
212                })
213                .collect();
214
215            Self {
216                buckets: Mutex::new(LruCache::new(capacity)),
217                policies,
218                default_policy: RoutePolicy {
219                    route: String::new(),
220                    max: DEFAULT_MAX,
221                    window: DEFAULT_WINDOW,
222                },
223            }
224        }
225
226        fn policy_for(&self, route: &str) -> &RoutePolicy {
227            self.policies
228                .iter()
229                .find(|p| p.route == route)
230                .unwrap_or(&self.default_policy)
231        }
232
233        fn check_sync(&self, key: &RateLimitKey<'_>, now: Instant) -> RateLimitDecision {
234            let policy = self.policy_for(key.route);
235            let canonical = key.canonical();
236
237            let mut buckets = self.buckets.lock();
238            let bucket = buckets.get_or_insert_mut(canonical, SlidingWindow::default);
239
240            bucket.prune(now, policy.window);
241
242            let window_secs = policy.window.as_secs().max(1);
243
244            if bucket.hits.len() as u32 >= policy.max {
245                // Retry-after = time until the oldest hit falls out of
246                // the window. Ceil to whole seconds so clients never
247                // retry slightly too early.
248                let oldest = bucket.hits.first().copied().unwrap_or(now);
249                let elapsed = now.saturating_duration_since(oldest);
250                let remaining = policy.window.saturating_sub(elapsed);
251                let retry_after_secs = ceil_secs(remaining).max(1);
252
253                return RateLimitDecision::Deny {
254                    retry_after_secs,
255                    limit: policy.max,
256                    window_secs,
257                };
258            }
259
260            bucket.hits.push(now);
261            RateLimitDecision::Allow
262        }
263    }
264
265    impl Default for LruRateLimiter {
266        fn default() -> Self {
267            Self::new()
268        }
269    }
270
271    #[async_trait]
272    impl RateLimiter for LruRateLimiter {
273        async fn check(&self, key: &RateLimitKey<'_>) -> RateLimitDecision {
274            // Synchronous under the hood — the mutex section is O(1)
275            // amortised. Exposed async so future backends (Redis,
276            // sharded) slot in without a trait change.
277            self.check_sync(key, Instant::now())
278        }
279    }
280
281    fn ceil_secs(d: Duration) -> u64 {
282        let whole = d.as_secs();
283        if d.subsec_nanos() > 0 {
284            whole.saturating_add(1)
285        } else {
286            whole
287        }
288    }
289
290    // --- unit tests ------------------------------------------------------
291
292    #[cfg(test)]
293    mod tests {
294        use super::*;
295        use std::net::{IpAddr, Ipv4Addr};
296
297        fn ip() -> RateLimitSubject<'static> {
298            RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))
299        }
300
301        #[test]
302        fn ceil_secs_rounds_up_fractional() {
303            assert_eq!(ceil_secs(Duration::from_millis(500)), 1);
304            assert_eq!(ceil_secs(Duration::from_secs(1)), 1);
305            assert_eq!(ceil_secs(Duration::from_millis(1500)), 2);
306            assert_eq!(ceil_secs(Duration::from_secs(0)), 0);
307        }
308
309        #[test]
310        fn default_policy_used_when_route_unknown() {
311            let limiter =
312                LruRateLimiter::with_policy(vec![("foo".into(), 1, Duration::from_secs(5))]);
313            let key = RateLimitKey {
314                route: "bar",
315                subject: ip(),
316            };
317            // First 60 should pass under the default policy.
318            let now = Instant::now();
319            for _ in 0..60 {
320                assert_eq!(limiter.check_sync(&key, now), RateLimitDecision::Allow);
321            }
322            // 61st denies.
323            let d = limiter.check_sync(&key, now);
324            assert!(matches!(d, RateLimitDecision::Deny { .. }));
325        }
326
327        #[test]
328        fn canonical_keys_separate_subjects() {
329            let a = RateLimitKey {
330                route: "r",
331                subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))),
332            };
333            let b = RateLimitKey {
334                route: "r",
335                subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 5))),
336            };
337            assert_ne!(a.canonical(), b.canonical());
338        }
339
340        #[test]
341        fn canonical_keys_separate_routes() {
342            let a = RateLimitKey {
343                route: "r1",
344                subject: ip(),
345            };
346            let b = RateLimitKey {
347                route: "r2",
348                subject: ip(),
349            };
350            assert_ne!(a.canonical(), b.canonical());
351        }
352    }
353}
354
355#[cfg(feature = "rate-limit")]
356pub use lru_impl::{LruRateLimiter, DEFAULT_LRU_CAPACITY};