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::tls::client_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;