Skip to main content

structured_proxy/shield/
mod.rs

1//! Shield: request rate limiting.
2//!
3//! The proxy runs embedded on each service instance, so every decision is made
4//! locally with a per-instance GCRA shaper ([`store::GcraStore`]) that adds no
5//! blocking latency to the request path. When a shared store is configured, a
6//! background task reconciles counters across instances asynchronously to
7//! approximate a fleet-wide limit; the request path never blocks on it.
8//!
9//! A request is limited by the first [rule](matcher::CompiledRule) whose glob
10//! matches its path. The rule's key selects *who* is limited (client IP, a
11//! header value, or a validated JWT claim) and its profile selects *how much*.
12
13pub mod gcra;
14#[cfg(feature = "redis")]
15pub mod global;
16pub mod matcher;
17pub mod rate;
18pub mod resolve;
19pub mod store;
20pub mod window;
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::Duration;
25
26use axum::extract::Request;
27use axum::http::HeaderMap;
28use axum::middleware::Next;
29use axum::response::Response;
30
31use crate::client_address::{ClientAddress, Resolution};
32use crate::config::ShieldConfig;
33use gcra::Verdict;
34use matcher::{CompiledProfile, CompiledRule, KeySource, Phase};
35use store::GcraStore;
36
37/// Compiled Shield rules, limit tiers, and the local GCRA store.
38pub struct Shield {
39    rules: Vec<CompiledRule>,
40    profiles: HashMap<String, CompiledProfile>,
41    /// Applied when a matched rule resolves no other limit.
42    default_profile: Option<CompiledProfile>,
43    /// Resolve a key's limit from validated JWT claims, when configured.
44    jwt_limits: Option<resolve::JwtLimits>,
45    /// Resolve a key's limit from an external service (cached, async).
46    limit_service: Option<Arc<resolve::LimitService>>,
47    /// Cross-instance reconciliation of the fleet-wide view (async, off the hot
48    /// path). Present only when a shared store is configured and compiled in.
49    #[cfg(feature = "redis")]
50    global: Option<Arc<global::GlobalCounters>>,
51    store: GcraStore,
52}
53
54impl Shield {
55    /// Build a Shield from config, or `None` when disabled / has no rules.
56    ///
57    /// # Errors
58    /// Returns an error string when a glob pattern, rate or profile reference
59    /// fails to compile.
60    pub fn build(config: &ShieldConfig) -> Result<Option<Arc<Self>>, String> {
61        if !config.enabled {
62            return Ok(None);
63        }
64        if config.rules.is_empty() {
65            // Fail loud rather than silently running unmetered: an upgrade that
66            // left an old `shield` schema (endpoint_classes / identifier_endpoints
67            // / redis_url) in place deserializes to zero rules, which would
68            // otherwise disable this security control while `enabled` is true.
69            return Err(
70                "shield.enabled is true but no rules are configured (note the schema: \
71                 profiles + rules + sync, not the older endpoint_classes/identifier_endpoints)"
72                    .to_string(),
73            );
74        }
75
76        let profiles = matcher::compile_profiles(&config.profiles)?;
77        let rules = matcher::compile_rules(&config.rules, &profiles)?;
78
79        let default_profile =
80            match &config.default_profile {
81                Some(name) => Some(*profiles.get(name).ok_or_else(|| {
82                    format!("default_profile references unknown profile {name:?}")
83                })?),
84                None => None,
85            };
86
87        let jwt_limits = config
88            .jwt_limits
89            .as_ref()
90            .map(resolve::JwtLimits::from_config);
91        let limit_service = match &config.limit_service {
92            Some(cfg) => Some(resolve::LimitService::build(cfg, profiles.clone())?),
93            None => None,
94        };
95
96        #[cfg(feature = "redis")]
97        let global = match &config.sync {
98            Some(sync) => {
99                let g = global::GlobalCounters::build(
100                    &sync.redis_url,
101                    Duration::from_millis(sync.interval_ms),
102                )?;
103                // Keep the fleet gate only if reconciliation actually started;
104                // otherwise fall back to per-instance limiting rather than gating
105                // on an estimate that would never be refreshed.
106                g.spawn().then_some(g)
107            }
108            None => None,
109        };
110        #[cfg(not(feature = "redis"))]
111        if config.sync.is_some() {
112            tracing::warn!(
113                "shield.sync is set but the `redis` feature is not compiled in; \
114                 staying local-only (per-instance limits)"
115            );
116        }
117
118        Ok(Some(Arc::new(Self {
119            rules,
120            profiles,
121            default_profile,
122            jwt_limits,
123            limit_service,
124            #[cfg(feature = "redis")]
125            global,
126            store: GcraStore::new(),
127        })))
128    }
129
130    /// The first rule in `phase` whose glob matches `path`. A path may match one
131    /// rule per phase; each phase enforces independently (two-phase by design:
132    /// a pre-auth IP/header rule and a post-auth claim rule can both apply).
133    fn match_rule(&self, path: &str, phase: Phase) -> Option<&CompiledRule> {
134        self.rules
135            .iter()
136            .find(|r| r.phase == phase && r.matcher.is_match(path))
137    }
138
139    /// Resolve the limit tier for a matched rule, in priority order: the JWT
140    /// itself (validated claims), then the external service (cached), then the
141    /// rule's pinned profile, then the default profile. `None` means no limit
142    /// applies and the request passes unmetered.
143    fn resolve_limit(
144        &self,
145        rule: &CompiledRule,
146        claims: Option<&serde_json::Value>,
147        identity: &str,
148    ) -> Option<CompiledProfile> {
149        if let (Some(jwt), Some(claims)) = (&self.jwt_limits, claims) {
150            if let Some(profile) = jwt.resolve(claims, &self.profiles) {
151                return Some(profile);
152            }
153        }
154        if let Some(service) = &self.limit_service {
155            if let Some(profile) = service.resolve(identity) {
156                return Some(profile);
157            }
158        }
159        self.static_profile(rule)
160    }
161
162    /// The rule's pinned profile, else the default profile.
163    fn static_profile(&self, rule: &CompiledRule) -> Option<CompiledProfile> {
164        rule.profile
165            .as_ref()
166            .and_then(|name| self.profiles.get(name))
167            .or(self.default_profile.as_ref())
168            .copied()
169    }
170}
171
172/// Pre-auth middleware: enforces rules that need no validated claims (IP /
173/// header keys). Layered outside auth so anonymous floods are shed before any
174/// signature verification.
175pub async fn pre_auth_middleware(
176    axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
177    request: Request,
178    next: Next,
179) -> Response {
180    enforce(&shield, Phase::PreAuth, request, next).await
181}
182
183/// Post-auth middleware: enforces rules keyed by a validated JWT claim. Layered
184/// inside auth so the verified claims are available on the request.
185pub async fn post_auth_middleware(
186    axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
187    request: Request,
188    next: Next,
189) -> Response {
190    enforce(&shield, Phase::PostAuth, request, next).await
191}
192
193/// Match a phase's rule for the request, apply its limit, and attach headers.
194async fn enforce(shield: &Shield, phase: Phase, request: Request, next: Next) -> Response {
195    let path = request.uri().path();
196    let Some(rule) = shield.match_rule(path, phase) else {
197        return next.run(request).await;
198    };
199
200    // The proxy resolved the client address before any guard. Shield used on
201    // its own, in a router of the embedder's, gets it from a
202    // `ClientAddressLayer` in front of it; without one it falls back to what
203    // resolution gives with no trusted proxy: the peer an axum server records.
204    let unresolved;
205    let client = match request.extensions().get::<ClientAddress>() {
206        Some(client) => client,
207        None => {
208            let peer = request
209                .extensions()
210                .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
211                .map(|ci| ci.0);
212            unresolved = ClientAddress::from_peer(peer);
213            &unresolved
214        }
215    };
216    let claims = request
217        .extensions()
218        .get::<crate::auth::ValidatedClaims>()
219        .map(|c| c.0.as_ref());
220    let key = rule_key(
221        &rule.fingerprint,
222        &rule.key,
223        client,
224        request.headers(),
225        claims,
226    );
227
228    // The limit service resolves per-principal, so it needs the real identity;
229    // the store / shared counter only need the de-identified `store` key.
230    let Some(profile) = shield.resolve_limit(rule, claims, &key.identity) else {
231        // No limit resolves for this rule (JWT/service/profile/default all
232        // absent): allow the request unmetered.
233        return next.run(request).await;
234    };
235
236    // Fleet gate first (read-only, cached), so the local shaper is not charged
237    // for a request the fleet-wide budget will reject. The fleet budget is the
238    // sustained rate (`profile.limit`); `burst` is deliberately a per-instance
239    // smoothing allowance, not a fleet-wide entitlement (honouring it fleet-wide
240    // would multiply the effective limit by N). A single instance's burst is
241    // therefore capped by the shared budget when the fleet is near it.
242    //
243    // Consequence for a `burst > limit` profile (e.g. `rate: "1/min", burst: 5`):
244    // under reconciliation the shared counter caps the key at the sustained
245    // `limit`, so the extra burst headroom that a local-only deployment would
246    // allow is not granted fleet-wide. This is intentional, not an oversight:
247    // gating on `burst` instead would let the fleet sustain `burst`-per-window,
248    // loosening the sustained cap by the burst factor. The conservative choice
249    // (never over-admit the fleet's sustained budget) wins; the local GCRA still
250    // smooths per-instance traffic.
251    #[cfg(feature = "redis")]
252    let fleet_remaining = shield
253        .global
254        .as_ref()
255        .map(|g| g.fleet_remaining(&key.store, profile.limit, profile.window));
256    #[cfg(feature = "redis")]
257    if fleet_remaining == Some(0) {
258        return global_reject(profile.limit, profile.window);
259    }
260
261    // The store key intentionally excludes the profile's numbers, so if a key's
262    // resolved tier changes (a JWT/service tier upgrade), the existing TAT is
263    // reused with the new emission interval. The TAT is an absolute time, so this
264    // only causes a brief transient at the change and self-corrects within one
265    // window. Keying by the tier's numbers instead would reset the budget on
266    // every tier flip, which a client could exploit to shed its own limit.
267    let verdict = shield.store.check(&key.store, &profile.gcra);
268    if !verdict.allowed {
269        return too_many_requests(profile.limit, verdict.remaining, &verdict);
270    }
271
272    // Report the tighter of the local and (when reconciled) fleet budgets, so a
273    // client near the fleet cap isn't told it has ample local room.
274    #[cfg(not(feature = "redis"))]
275    let (reported, header_verdict) = reconciled_headers(verdict, None, profile.window);
276    #[cfg(feature = "redis")]
277    let (reported, header_verdict) = {
278        // Record the admit for the next reconciliation push (whenever the fleet
279        // gate is active, i.e. `fleet_remaining` was computed).
280        if fleet_remaining.is_some() {
281            if let Some(global) = &shield.global {
282                global.record(&key.store, profile.window);
283            }
284        }
285        reconciled_headers(verdict, fleet_remaining, profile.window)
286    };
287
288    let mut response = next.run(request).await;
289    // Report the tightest budget across phases. With defense-in-depth (a pre-auth
290    // and a post-auth rule on the same path), an inner limiter may already have
291    // set headers on the way out; overwrite them only when this (outer) phase's
292    // remaining is smaller, so the client always sees the budget that will bite
293    // first. An inner rejection carries remaining 0, so it is never overwritten.
294    maybe_tighten_rate_headers(
295        response.headers_mut(),
296        profile.limit,
297        reported,
298        &header_verdict,
299    );
300    response
301}
302
303/// Combine the local GCRA `verdict` with the optional fleet remaining into the
304/// reported remaining and the verdict whose reset drives the headers. The count
305/// is the tighter of local and fleet, minus this admit (`fr - 1`).
306fn reconciled_headers(
307    verdict: Verdict,
308    fleet_remaining: Option<u64>,
309    window: Duration,
310) -> (u64, Verdict) {
311    let mut reported = verdict.remaining;
312    let mut hv = verdict;
313    if let Some(fr) = fleet_remaining {
314        let fleet_r = fr.saturating_sub(1);
315        if fleet_r <= reported {
316            // The fleet budget binds. Advertise the fleet-derived reset (the
317            // shared sliding-window estimate can stay saturated far longer than
318            // this instance's local GCRA), so a client pacing off the allowed
319            // response does not retry before the window decays and immediately
320            // hit the fleet gate. Widen, never shrink: keep the local reset if it
321            // is already the longer wait.
322            reported = fleet_r;
323            let backoff = fleet_backoff(window);
324            hv.reset_after = hv.reset_after.max(backoff);
325            hv.retry_after = hv.retry_after.max(backoff);
326        }
327    }
328    (reported, hv)
329}
330
331/// Poll interval for a client blocked (or nearly blocked) by the fleet gate: a
332/// tenth of the window, at least 1s. The fleet's sliding-window estimate decays
333/// continuously rather than freeing at an epoch boundary, so this is a retry
334/// cadence, not a wait-to-boundary.
335fn fleet_backoff(window: Duration) -> Duration {
336    (window / 10).max(Duration::from_secs(1))
337}
338
339/// Set the `RateLimit-*` headers unless an inner limiter already advertised a
340/// budget that binds at least as hard, which must reach the client intact.
341/// "Binds harder" is a smaller `remaining`, and on a `remaining` tie the larger
342/// `reset` wins: with both phases at `remaining=0`, a client pacing off the
343/// headers must see the longest wait (e.g. an hourly IP cap over a per-minute
344/// principal cap), or it retries early and immediately hits the outer limit.
345fn maybe_tighten_rate_headers(
346    headers: &mut HeaderMap,
347    limit: u64,
348    remaining: u64,
349    verdict: &Verdict,
350) {
351    let header_u64 = |name: &str| {
352        headers
353            .get(name)
354            .and_then(|v| v.to_str().ok())
355            .and_then(|v| v.parse::<u64>().ok())
356    };
357    let keep_inner = match header_u64("ratelimit-remaining") {
358        Some(inner) if inner < remaining => true,
359        // Tie on remaining: keep the inner headers only if their reset is at
360        // least as long as this phase's, so the longer-binding budget survives.
361        Some(inner) if inner == remaining => {
362            header_u64("ratelimit-reset").unwrap_or(0) >= secs_ceil(verdict.reset_after)
363        }
364        _ => false,
365    };
366    if !keep_inner {
367        attach_rate_headers(headers, limit, remaining, verdict);
368        // On an error response the rejecting layer already set `Retry-After`. We
369        // just replaced the budget with a longer-binding one, so widen
370        // `Retry-After` to that reset too; otherwise the client retries after the
371        // overwritten (shorter) wait and immediately hits this binding budget.
372        // Only ever widen: a 200 has no `Retry-After` to touch, and an already
373        // longer wait is left intact.
374        if let Some(current) = headers
375            .get("retry-after")
376            .and_then(|v| v.to_str().ok())
377            .and_then(|v| v.parse::<u64>().ok())
378        {
379            let reset = secs_ceil(verdict.reset_after);
380            if reset > current {
381                if let Ok(v) = reset.to_string().parse() {
382                    headers.insert("retry-after", v);
383                }
384            }
385        }
386    }
387}
388
389/// A `429` for a request rejected by the fleet-wide gate. The sliding-window
390/// estimate decays continuously (it does not free capacity at the epoch
391/// boundary), so `Retry-After` is a modest poll interval rather than the time to
392/// the boundary, which a client could wait out and still be rejected.
393#[cfg(feature = "redis")]
394fn global_reject(limit: u64, window: Duration) -> Response {
395    let backoff = fleet_backoff(window);
396    let verdict = Verdict {
397        allowed: false,
398        new_tat: Duration::ZERO,
399        remaining: 0,
400        retry_after: backoff,
401        reset_after: backoff,
402    };
403    too_many_requests(limit, 0, &verdict)
404}
405
406/// The keys a matched rule derives for one request.
407struct RuleKey {
408    /// De-identified key for the local store and shared counter: the rule's
409    /// stable fingerprint, the source tag, and a hash of the value. Raw client
410    /// values (API keys, principals, IPs) never reach the shared store or its
411    /// logs. Deterministic across instances so reconciliation keys agree.
412    store: String,
413    /// The raw identity for per-principal limit-service resolution (the service
414    /// must see the real principal to resolve its tier). Not persisted.
415    identity: String,
416}
417
418/// Derive the store key and resolution identity for a matched rule. Every source
419/// falls back to the client address when its value is absent, so a limit can't
420/// be dodged by omitting a header or authenticating anonymously (subject to the
421/// rule's phase: a `jwt_claim` rule only runs post-auth).
422fn rule_key(
423    fingerprint: &str,
424    key: &KeySource,
425    client: &ClientAddress,
426    headers: &HeaderMap,
427    claims: Option<&serde_json::Value>,
428) -> RuleKey {
429    let (tag, identity) = match key {
430        KeySource::Ip => ("ip", address_identity(client)),
431        KeySource::Header(name) => match header_str(headers, name) {
432            Some(v) => ("hdr", v),
433            None => ("ip", address_identity(client)),
434        },
435        KeySource::JwtClaim(claim) => match claims.and_then(|c| resolve::claim_str(c, claim)) {
436            Some(v) => ("jwt", v),
437            None => ("ip", address_identity(client)),
438        },
439    };
440    RuleKey {
441        store: format!("{fingerprint}:{tag}:{}", matcher::short_hash(&identity)),
442        identity,
443    }
444}
445
446/// The identity a client address keys a limit by: the address, or one shared
447/// bucket per reason none resolved. A request that cannot name its client
448/// shares its budget with every other such request rather than escaping the
449/// limit, and a proxy's own address never stands in for the client's.
450fn address_identity(client: &ClientAddress) -> String {
451    match client.resolution() {
452        Resolution::Peer(ip) | Resolution::Forwarded(ip) => ip.to_string(),
453        Resolution::Invalid(_) => "invalid".to_string(),
454        Resolution::Unavailable => "unknown".to_string(),
455    }
456}
457
458/// Trimmed, non-empty value of a header.
459fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
460    headers
461        .get(name)
462        .and_then(|v| v.to_str().ok())
463        .map(str::trim)
464        .filter(|s| !s.is_empty())
465        .map(str::to_string)
466}
467
468/// Attach the draft-ietf `RateLimit-*` headers describing the remaining budget.
469/// `remaining` is passed explicitly (rather than read from the verdict) so the
470/// caller can report the tighter of the local and fleet budgets.
471fn attach_rate_headers(headers: &mut HeaderMap, limit: u64, remaining: u64, verdict: &Verdict) {
472    if let Ok(v) = limit.to_string().parse() {
473        headers.insert("ratelimit-limit", v);
474    }
475    if let Ok(v) = remaining.to_string().parse() {
476        headers.insert("ratelimit-remaining", v);
477    }
478    if let Ok(v) = secs_ceil(verdict.reset_after).to_string().parse() {
479        headers.insert("ratelimit-reset", v);
480    }
481}
482
483/// A `429` response carrying the rate-limit headers plus `Retry-After`.
484fn too_many_requests(limit: u64, remaining: u64, verdict: &Verdict) -> Response {
485    let mut response = crate::guard::reject(tonic::Code::ResourceExhausted, "rate limit exceeded");
486    let headers = response.headers_mut();
487    attach_rate_headers(headers, limit, remaining, verdict);
488    if let Ok(v) = secs_ceil(verdict.retry_after).to_string().parse() {
489        headers.insert("retry-after", v);
490    }
491    response
492}
493
494/// Whole seconds, rounded up, for `Retry-After` / `RateLimit-Reset` (never report
495/// `0` for a non-zero wait).
496fn secs_ceil(d: Duration) -> u64 {
497    // Round up from nanoseconds, not truncated millis: a sub-millisecond wait
498    // must still report at least one second, never zero.
499    let nanos = d.as_nanos();
500    u64::try_from(nanos.div_ceil(1_000_000_000)).unwrap_or(u64::MAX)
501}
502
503#[cfg(test)]
504mod tests;