Skip to main content

structured_proxy/shield/
resolve.rs

1//! Limit resolution: derive a key's `{rate, burst}` from the JWT itself or from
2//! an external service, on top of the static config profiles.
3//!
4//! The resolution chain is `jwt → service → rule profile → default`. JWT
5//! resolution is synchronous (the validated claims ride on the request). Service
6//! resolution never blocks the request path: a lookup is cached and refreshed in
7//! the background (stale-while-revalidate), so a request either uses a cached
8//! limit or falls through to the static profile while the fetch happens.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use dashmap::mapref::entry::Entry;
15use serde::Deserialize;
16use serde_json::Value;
17
18use super::gcra::{Gcra, Profile};
19use super::matcher::CompiledProfile;
20use crate::config::{JwtLimitConfig, LimitServiceConfig};
21
22/// Bare / per-minute rates use this window.
23const PER_MINUTE: Duration = Duration::from_secs(60);
24
25/// Resolve a (possibly dotted) claim path to a scalar string value.
26pub(super) fn claim_str(claims: &Value, path: &str) -> Option<String> {
27    match claim_at(claims, path)? {
28        Value::String(s) => Some(s.clone()),
29        Value::Number(n) => Some(n.to_string()),
30        Value::Bool(b) => Some(b.to_string()),
31        _ => None,
32    }
33}
34
35/// Resolve a (possibly dotted) claim path to an unsigned integer, accepting a
36/// numeric or a numeric-string value.
37fn claim_u64(claims: &Value, path: &str) -> Option<u64> {
38    match claim_at(claims, path)? {
39        Value::Number(n) => n.as_u64(),
40        Value::String(s) => s.trim().parse().ok(),
41        _ => None,
42    }
43}
44
45fn claim_at<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
46    let mut cur = claims;
47    for seg in path.split('.') {
48        cur = cur.get(seg)?;
49    }
50    Some(cur)
51}
52
53/// Build a limit tier from explicit per-minute numbers. Returns `None` for a
54/// zero rate: like a static profile, a dynamic `0` is not a usable limit (it
55/// would otherwise clamp to 1 and silently grant a request per minute), so the
56/// caller falls through to the next resolver. Blocking a principal outright is
57/// an authorization concern, not a rate limit.
58fn profile_from_numbers(rpm: u64, burst: u64) -> Option<CompiledProfile> {
59    if rpm == 0 {
60        return None;
61    }
62    let gcra = Gcra::from_profile(Profile {
63        rate: rpm,
64        window: PER_MINUTE,
65        burst: burst.max(1),
66    });
67    Some(CompiledProfile {
68        gcra,
69        limit: rpm,
70        window: PER_MINUTE,
71    })
72}
73
74/// Compiled JWT-based limit resolution: the claim names to read from the token.
75#[derive(Debug, Clone)]
76pub struct JwtLimits {
77    tier_claim: String,
78    rpm_claim: String,
79    burst_claim: String,
80}
81
82impl JwtLimits {
83    /// Compile from config.
84    pub fn from_config(cfg: &JwtLimitConfig) -> Self {
85        Self {
86            tier_claim: cfg.tier_claim.clone(),
87            rpm_claim: cfg.rpm_claim.clone(),
88            burst_claim: cfg.burst_claim.clone(),
89        }
90    }
91
92    /// Resolve a limit from the validated claims: a tier-name claim naming a
93    /// profile takes precedence, then explicit `rpm` (+ optional `burst`)
94    /// numbers. `None` when the token carries no limit hints.
95    pub fn resolve(
96        &self,
97        claims: &Value,
98        profiles: &HashMap<String, CompiledProfile>,
99    ) -> Option<CompiledProfile> {
100        if let Some(tier) = claim_str(claims, &self.tier_claim) {
101            if let Some(profile) = profiles.get(&tier) {
102                return Some(*profile);
103            }
104        }
105        if let Some(rpm) = claim_u64(claims, &self.rpm_claim) {
106            let burst = claim_u64(claims, &self.burst_claim).unwrap_or(rpm);
107            return profile_from_numbers(rpm, burst);
108        }
109        None
110    }
111}
112
113/// External limit-service response: a tier name or explicit numbers.
114#[derive(Debug, Deserialize)]
115struct LimitResponse {
116    #[serde(default)]
117    tier: Option<String>,
118    #[serde(default)]
119    rate_per_min: Option<u64>,
120    #[serde(default)]
121    burst: Option<u64>,
122}
123
124/// A cached resolution. `profile` is `None` when the service reported no limit
125/// for the key (a negative cache entry stops us re-querying every request).
126#[derive(Clone, Copy)]
127struct Cached {
128    profile: Option<CompiledProfile>,
129    /// When the value was last fetched (drives staleness / refresh age).
130    at: Instant,
131    /// When the entry was last read (drives idle eviction). Advances on every
132    /// access, including stale hits, so an actively-used key is not evicted
133    /// during a service outage that keeps `at` from advancing.
134    last_access: Instant,
135}
136
137/// Sweep the cache of long-idle keys at most once per this interval.
138const SWEEP_INTERVAL: Duration = Duration::from_secs(60);
139
140/// Hard cap on cached resolutions, bounding peak memory under rapid key rotation
141/// (idle eviction bounds retention time, not peak cardinality). When full, the
142/// least-recently-accessed entries are dropped: an attacker's one-shot keys are
143/// the oldest and get evicted, while an actively-reused key stays warm.
144const MAX_CACHE_ENTRIES: usize = 100_000;
145
146/// Cap on concurrent background limit-service fetches, bounding outbound calls
147/// and tasks when many distinct keys miss at once.
148const MAX_CONCURRENT_FETCHES: usize = 32;
149
150/// External limit-resolution service with an async, non-blocking cache.
151pub struct LimitService {
152    endpoint: String,
153    ttl: Duration,
154    /// Drop cache entries not refreshed within this window (a key that stopped
155    /// receiving requests), so client-controlled key cardinality can't grow the
156    /// cache without bound.
157    evict_after: Duration,
158    client: reqwest::Client,
159    /// Profiles for mapping a returned tier name to a compiled limit.
160    profiles: HashMap<String, CompiledProfile>,
161    cache: dashmap::DashMap<String, Cached>,
162    /// Keys with a background fetch already in flight (dedupes refreshes).
163    inflight: dashmap::DashMap<String, ()>,
164    /// Global cap on concurrent background fetches (in addition to per-key dedup).
165    fetch_slots: Arc<tokio::sync::Semaphore>,
166    base: Instant,
167    last_sweep_ms: std::sync::atomic::AtomicU64,
168}
169
170impl LimitService {
171    /// Build the service client from config and the compiled profiles.
172    ///
173    /// # Errors
174    /// Returns an error string when the HTTP client cannot be constructed.
175    pub fn build(
176        cfg: &LimitServiceConfig,
177        profiles: HashMap<String, CompiledProfile>,
178    ) -> Result<Arc<Self>, String> {
179        // Validate the endpoint at build time: a malformed or non-HTTP URL is a
180        // config error for a security control, so fail startup rather than
181        // silently disabling dynamic limits when the background fetch later fails.
182        let url = reqwest::Url::parse(&cfg.endpoint)
183            .map_err(|e| format!("invalid limit_service.endpoint {:?}: {e}", cfg.endpoint))?;
184        if !matches!(url.scheme(), "http" | "https") {
185            return Err(format!(
186                "limit_service.endpoint must be http/https, got scheme {:?}",
187                url.scheme()
188            ));
189        }
190        let client = reqwest::Client::builder()
191            .timeout(Duration::from_millis(cfg.timeout_ms.max(1)))
192            .tls_backend_preconfigured(crate::auth::jwks::build_tls_config())
193            .build()
194            .map_err(|e| format!("invalid limit_service client: {e}"))?;
195        let ttl = Duration::from_secs(cfg.ttl_secs.max(1));
196        Ok(Arc::new(Self {
197            endpoint: cfg.endpoint.clone(),
198            ttl,
199            // Keep an idle entry for a few refresh cycles, at least 5 minutes.
200            evict_after: (ttl * 4).max(Duration::from_secs(300)),
201            client,
202            profiles,
203            cache: dashmap::DashMap::new(),
204            inflight: dashmap::DashMap::new(),
205            fetch_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FETCHES)),
206            base: Instant::now(),
207            last_sweep_ms: std::sync::atomic::AtomicU64::new(0),
208        }))
209    }
210
211    /// Evict cache entries not refreshed within `evict_after`, at most once per
212    /// [`SWEEP_INTERVAL`]; the first caller past the interval claims the sweep.
213    fn maybe_sweep(&self) {
214        use std::sync::atomic::Ordering;
215        let now_ms = u64::try_from(self.base.elapsed().as_millis()).unwrap_or(u64::MAX);
216        let last = self.last_sweep_ms.load(Ordering::Relaxed);
217        if now_ms.saturating_sub(last) < SWEEP_INTERVAL.as_millis() as u64 {
218            return;
219        }
220        if self
221            .last_sweep_ms
222            .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
223            .is_ok()
224        {
225            self.sweep();
226        }
227    }
228
229    /// Drop entries not accessed within `evict_after` (idle keys), keyed on last
230    /// access rather than last fetch so an active-but-unrefreshable key survives.
231    /// Then enforce the hard capacity as a backstop, evicting the
232    /// least-recently-accessed entries. Growth is primarily bounded at insertion
233    /// (see [`insert_capped`](Self::insert_capped)); this trim only reclaims the
234    /// rare overshoot from concurrent inserts racing the soft cap check.
235    fn sweep(&self) {
236        let evict_after = self.evict_after;
237        self.cache
238            .retain(|_, c| c.last_access.elapsed() < evict_after);
239        if self.cache.len() > MAX_CACHE_ENTRIES {
240            let mut ages: Vec<(String, Instant)> = self
241                .cache
242                .iter()
243                .map(|e| (e.key().clone(), e.value().last_access))
244                .collect();
245            // Oldest first; drop everything past the cap.
246            ages.sort_by_key(|(_, t)| *t);
247            for (key, _) in ages.into_iter().take(self.cache.len() - MAX_CACHE_ENTRIES) {
248                self.cache.remove(&key);
249            }
250        }
251    }
252
253    /// Resolve `key`'s limit from the cache, serving a stale value while a
254    /// background refresh runs. Returns `None` (fall through to the static
255    /// profile) only when nothing is cached yet. Never blocks the request.
256    pub fn resolve(self: &Arc<Self>, key: &str) -> Option<CompiledProfile> {
257        self.maybe_sweep();
258        // Bump last-access (even for a stale hit) so an actively-used key is kept
259        // through an outage, then read the cached value.
260        let cached = self.cache.get_mut(key).map(|mut c| {
261            c.last_access = Instant::now();
262            *c
263        });
264        match cached {
265            Some(c) if c.at.elapsed() < self.ttl => c.profile,
266            Some(c) => {
267                // Stale: serve the last value and refresh in the background.
268                self.trigger_refresh(key.to_string());
269                c.profile
270            }
271            None => {
272                self.trigger_refresh(key.to_string());
273                None
274            }
275        }
276    }
277
278    /// Spawn a single background fetch for `key` (deduped by `inflight`, and
279    /// globally bounded by `fetch_slots`). When no fetch slot is free, skip the
280    /// refresh and serve the existing stale/static result rather than piling up
281    /// outbound calls under a burst of distinct keys.
282    /// Insert or refresh a cache entry, refusing to create a *new* entry once the
283    /// cache is at capacity. A completed fetch inserts from a background task, so
284    /// without this a fast service plus rotating identities could push the map
285    /// far past [`MAX_CACHE_ENTRIES`] between the 60s sweeps; refusing new entries
286    /// at the cap degrades those keys to the static profile (the same tolerance as
287    /// a skipped refresh). An existing key is always updated.
288    fn insert_capped(&self, key: String, cached: Cached) {
289        // Read len() before taking the entry: DashMap::len() read-locks every
290        // shard, and holding a shard write-lock (via entry) while doing so would
291        // deadlock. The cap is a soft memory guard, so the race is harmless.
292        let over_cap = self.cache.len() >= MAX_CACHE_ENTRIES;
293        match self.cache.entry(key) {
294            Entry::Occupied(mut o) => {
295                o.insert(cached);
296            }
297            Entry::Vacant(_) if over_cap => {} // at capacity: drop the new entry
298            Entry::Vacant(v) => {
299                v.insert(cached);
300            }
301        }
302    }
303
304    fn trigger_refresh(self: &Arc<Self>, key: String) {
305        match self.inflight.entry(key.clone()) {
306            Entry::Occupied(_) => return,
307            Entry::Vacant(v) => {
308                v.insert(());
309            }
310        }
311        let permit = match self.fetch_slots.clone().try_acquire_owned() {
312            Ok(p) => p,
313            Err(_) => {
314                self.inflight.remove(&key);
315                return;
316            }
317        };
318        let this = self.clone();
319        tokio::spawn(async move {
320            let _permit = permit; // released when the fetch task ends
321            match this.fetch(&key).await {
322                Ok(resolved) => {
323                    let now = Instant::now();
324                    this.insert_capped(
325                        key.clone(),
326                        Cached {
327                            profile: resolved,
328                            at: now,
329                            last_access: now,
330                        },
331                    );
332                }
333                Err(()) => {
334                    // Throttle retries during an outage. Reset the fetch timestamp
335                    // (`at`) so the entry is treated as fresh for another TTL and
336                    // the next request serves it without immediately re-fetching;
337                    // otherwise a hot stale key would spawn one outbound call per
338                    // request. Keep the last-good profile for an existing entry
339                    // (fail-static); negative-cache a brand-new key so a client
340                    // rotating key values can't spawn unbounded fetch tasks.
341                    let now = Instant::now();
342                    match this.cache.get_mut(&key) {
343                        Some(mut c) => c.at = now,
344                        None => {
345                            this.insert_capped(
346                                key.clone(),
347                                Cached {
348                                    profile: None,
349                                    at: now,
350                                    last_access: now,
351                                },
352                            );
353                        }
354                    }
355                }
356            }
357            this.inflight.remove(&key);
358        });
359    }
360
361    /// Query the service for `key` and map the response to a limit. `Ok(None)`
362    /// means the service reported no limit; `Err` means the lookup failed and the
363    /// cache should be left untouched.
364    async fn fetch(&self, key: &str) -> Result<Option<CompiledProfile>, ()> {
365        let url = reqwest::Url::parse_with_params(&self.endpoint, &[("key", key)])
366            .map_err(|e| tracing::warn!("invalid limit-service endpoint: {e}"))?;
367        let resp = self
368            .client
369            .get(url)
370            .send()
371            .await
372            .map_err(|e| tracing::warn!("limit-service fetch failed: {e}"))?;
373        if !resp.status().is_success() {
374            // A 404 is a definitive "no limit for this key": cache it negatively.
375            if resp.status() == reqwest::StatusCode::NOT_FOUND {
376                return Ok(None);
377            }
378            tracing::warn!("limit-service returned {}", resp.status());
379            return Err(());
380        }
381        let body: LimitResponse = resp
382            .json()
383            .await
384            .map_err(|e| tracing::warn!("limit-service response parse failed: {e}"))?;
385        Ok(self.map_response(body))
386    }
387
388    /// Map a service response to a compiled limit: a tier name resolves against
389    /// the configured profiles; otherwise explicit numbers apply.
390    fn map_response(&self, body: LimitResponse) -> Option<CompiledProfile> {
391        if let Some(tier) = &body.tier {
392            if let Some(profile) = self.profiles.get(tier) {
393                return Some(*profile);
394            }
395            // Unknown tier (e.g. mid rollout): fall through to explicit numbers if
396            // the response also carried them, matching JWT resolution.
397        }
398        body.rate_per_min
399            .and_then(|rpm| profile_from_numbers(rpm, body.burst.unwrap_or(rpm)))
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn profiles() -> HashMap<String, CompiledProfile> {
408        let mut m = HashMap::new();
409        m.insert(
410            "premium".to_string(),
411            profile_from_numbers(1000, 100).unwrap(), // limit 1000
412        );
413        m
414    }
415
416    fn jwt_limits() -> JwtLimits {
417        JwtLimits::from_config(&JwtLimitConfig {
418            tier_claim: "ratelimit_tier".to_string(),
419            rpm_claim: "ratelimit_rpm".to_string(),
420            burst_claim: "ratelimit_burst".to_string(),
421        })
422    }
423
424    #[test]
425    fn jwt_tier_name_maps_to_profile() {
426        let claims = serde_json::json!({ "ratelimit_tier": "premium" });
427        let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
428        assert_eq!(p.limit, 1000);
429    }
430
431    #[test]
432    fn jwt_direct_numbers_build_a_profile() {
433        let claims = serde_json::json!({ "ratelimit_rpm": 300, "ratelimit_burst": 30 });
434        let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
435        assert_eq!(p.limit, 300);
436    }
437
438    #[test]
439    fn jwt_tier_takes_precedence_over_numbers() {
440        let claims = serde_json::json!({ "ratelimit_tier": "premium", "ratelimit_rpm": 5 });
441        let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
442        assert_eq!(p.limit, 1000);
443    }
444
445    #[test]
446    fn jwt_unknown_tier_falls_through_to_numbers_then_none() {
447        // Unknown tier + no numbers → no resolution.
448        let claims = serde_json::json!({ "ratelimit_tier": "gold" });
449        assert!(jwt_limits().resolve(&claims, &profiles()).is_none());
450        // Unknown tier + numbers → numbers win.
451        let claims = serde_json::json!({ "ratelimit_tier": "gold", "ratelimit_rpm": 42 });
452        assert_eq!(
453            jwt_limits().resolve(&claims, &profiles()).unwrap().limit,
454            42
455        );
456    }
457
458    #[tokio::test]
459    async fn actively_used_stale_entry_survives_eviction() {
460        use crate::config::LimitServiceConfig;
461        let svc = LimitService::build(
462            &LimitServiceConfig {
463                endpoint: "http://127.0.0.1:0/".to_string(),
464                ttl_secs: 1,
465                timeout_ms: 50,
466            },
467            profiles(),
468        )
469        .unwrap();
470        // Simulate a service outage: the last successful fetch was long ago, so
471        // `at` is stale and well past evict_after, but the key is still in active
472        // use right now.
473        let old = Instant::now()
474            .checked_sub(Duration::from_secs(600))
475            .expect("clock supports the offset");
476        svc.cache.insert(
477            "k".to_string(),
478            Cached {
479                profile: Some(profile_from_numbers(10, 10).unwrap()),
480                at: old,
481                last_access: old,
482            },
483        );
484        let _ = svc.resolve("k");
485        svc.sweep();
486        assert!(
487            svc.cache.contains_key("k"),
488            "an actively-used stale entry must not be evicted during an outage"
489        );
490    }
491
492    fn service(endpoint: &str) -> Arc<LimitService> {
493        LimitService::build(
494            &LimitServiceConfig {
495                endpoint: endpoint.to_string(),
496                ttl_secs: 60,
497                timeout_ms: 50,
498            },
499            profiles(),
500        )
501        .unwrap()
502    }
503
504    #[test]
505    fn service_unknown_tier_falls_through_to_numbers() {
506        let svc = service("http://127.0.0.1:9/");
507        // Unknown tier but explicit numbers present → numbers win (like JWT).
508        let p = svc
509            .map_response(LimitResponse {
510                tier: Some("gold".to_string()),
511                rate_per_min: Some(50),
512                burst: None,
513            })
514            .unwrap();
515        assert_eq!(p.limit, 50);
516    }
517
518    #[test]
519    fn build_rejects_non_http_endpoint() {
520        // Syntactically valid URLs that reqwest GET can't use must be rejected.
521        for ep in ["redis://127.0.0.1/", "file:///etc/passwd", "ftp://h/x"] {
522            let r = LimitService::build(
523                &LimitServiceConfig {
524                    endpoint: ep.to_string(),
525                    ttl_secs: 60,
526                    timeout_ms: 50,
527                },
528                profiles(),
529            );
530            assert!(r.is_err(), "expected {ep} to be rejected");
531        }
532    }
533
534    #[test]
535    fn build_rejects_malformed_endpoint() {
536        let bad = LimitService::build(
537            &LimitServiceConfig {
538                endpoint: "not a url".to_string(),
539                ttl_secs: 60,
540                timeout_ms: 50,
541            },
542            profiles(),
543        );
544        assert!(bad.is_err());
545    }
546
547    #[test]
548    fn jwt_zero_rate_is_not_a_usable_limit() {
549        // A dynamic rate of 0 must not clamp to 1; it yields no limit so the
550        // caller falls through to the next resolver.
551        let claims = serde_json::json!({ "ratelimit_rpm": 0 });
552        assert!(jwt_limits().resolve(&claims, &profiles()).is_none());
553    }
554
555    #[test]
556    fn numeric_string_claims_are_accepted() {
557        let claims = serde_json::json!({ "ratelimit_rpm": "250" });
558        assert_eq!(
559            jwt_limits().resolve(&claims, &profiles()).unwrap().limit,
560            250
561        );
562    }
563}