Skip to main content

guard_core_engine/
route_config.rs

1//! The per-route configuration carrier: the reference `RouteConfig`
2//! (`guard_core/decorators/route_config.py`).
3//!
4//! The 30 knobs the `@route_config` decorator accepts, as an
5//! engine-owned struct plus the resolution seam the adapters attach per
6//! route. Every knob is `None`/empty/false unless the route sets it: the
7//! route-level value overrides the global `SecurityConfig` value for that
8//! route only (the reference `RouteConfigResolver` semantics - the global
9//! config is the fallback, the route config the override).
10//!
11//! The two carriers the engine already had - [`crate::rate_limit::
12//! RouteRateLimits`] and [`crate::detection_exclusions::
13//! RouteDetectionExclusions`] - are the views of this struct's rate-limit
14//! and detection groups; the bridge methods hand them out so the existing
15//! resolver seams consume one carrier.
16//!
17//! # Strict route resolution
18//!
19//! The reference `RouteConfigCheck` answers a request the adapter reported
20//! as unresolved (`guard_route_unresolved`) with a `500` when
21//! `route_resolution_strict` is set (a log-only event under `passive_mode`).
22//! The constants below are that answer, shared by the adapters.
23//!
24//! # Example
25//!
26//! ```
27//! use std::sync::Arc;
28//!
29//! use guard_core_engine::route_config::{RouteConfig, RouteConfigResolver};
30//!
31//! let resolver: RouteConfigResolver = Arc::new(|method, path| {
32//!     (method == "POST" && path == "/login").then(|| {
33//!         Arc::new(RouteConfig {
34//!             rate_limit: Some(5),
35//!             require_https: true,
36//!             ..RouteConfig::default()
37//!         })
38//!     })
39//! });
40//! let config = resolver("POST", "/login").expect("route config");
41//! assert_eq!(config.rate_limit, Some(5));
42//! assert!(resolver("GET", "/login").is_none());
43//! ```
44
45use std::collections::{BTreeMap, BTreeSet, HashMap};
46use std::sync::Arc;
47
48use crate::behavior::BehaviorRule;
49use crate::custom_checks::CustomValidatorFn;
50use crate::detection_exclusions::RouteDetectionExclusions;
51use crate::headers_auth::AuthVerifier;
52use crate::rate_limit::RouteRateLimits;
53
54/// The per-route configuration carrier (the reference `RouteConfig`'s 30
55/// knobs). `None`/empty inherits the global config for that knob.
56#[derive(Clone)]
57pub struct RouteConfig {
58    /// `rate_limit`: the route's request count per window.
59    pub rate_limit: Option<u32>,
60    /// `rate_limit_window`: the route's window (seconds).
61    pub rate_limit_window: Option<u64>,
62    /// `ip_whitelist`: the route's allow list.
63    pub ip_whitelist: Option<Vec<String>>,
64    /// `ip_blacklist`: the route's deny list.
65    pub ip_blacklist: Option<Vec<String>>,
66    /// `blocked_countries`.
67    pub blocked_countries: Option<Vec<String>>,
68    /// `whitelist_countries`.
69    pub whitelist_countries: Option<Vec<String>>,
70    /// `bypassed_checks`: the check names this route skips.
71    pub bypassed_checks: BTreeSet<String>,
72    /// `require_https`.
73    pub require_https: bool,
74    /// `auth_required`: the authentication scheme the route demands.
75    pub auth_required: Option<String>,
76    /// `custom_validators`: the route's validator functions.
77    pub custom_validators: Vec<CustomValidatorFn>,
78    /// `blocked_user_agents`.
79    pub blocked_user_agents: Vec<String>,
80    /// `required_headers`: header name to exact value.
81    pub required_headers: BTreeMap<String, String>,
82    /// `behavior_rules`: the route's behavior rules.
83    pub behavior_rules: Vec<BehaviorRule>,
84    /// `block_cloud_providers`.
85    pub block_cloud_providers: BTreeSet<String>,
86    /// `max_request_size` (bytes).
87    pub max_request_size: Option<u64>,
88    /// `allowed_content_types`.
89    pub allowed_content_types: Option<Vec<String>>,
90    /// `time_restrictions`.
91    pub time_restrictions: Option<BTreeMap<String, String>>,
92    /// `enable_suspicious_detection` (the reference default `True`).
93    pub enable_suspicious_detection: bool,
94    /// `require_referrer`.
95    pub require_referrer: Option<Vec<String>>,
96    /// `api_key_required`.
97    pub api_key_required: bool,
98    /// `auth_verifier`: the route's authentication verifier.
99    pub auth_verifier: Option<AuthVerifier>,
100    /// `api_key_verifier`: the route's API-key verifier.
101    pub api_key_verifier: Option<AuthVerifier>,
102    /// `api_key_header`.
103    pub api_key_header: Option<String>,
104    /// `authorization_header_required`.
105    pub authorization_header_required: Option<String>,
106    /// `geo_rate_limits`: country code to (count, window seconds).
107    pub geo_rate_limits: Option<BTreeMap<String, (u32, u64)>>,
108    /// `excluded_detection_headers`.
109    pub excluded_detection_headers: Option<BTreeSet<String>>,
110    /// `excluded_semantic_detection_headers`.
111    pub excluded_semantic_detection_headers: Option<BTreeSet<String>>,
112    /// `excluded_detection_params`.
113    pub excluded_detection_params: Option<BTreeSet<String>>,
114    /// `excluded_detection_body_fields`.
115    pub excluded_detection_body_fields: Option<BTreeSet<String>>,
116    /// `enabled_detection_categories`.
117    pub enabled_detection_categories: Option<BTreeSet<String>>,
118    /// `detection_scan_body`.
119    pub detection_scan_body: Option<bool>,
120}
121
122impl Default for RouteConfig {
123    /// The reference defaults: every override `None`/empty, the two
124    /// explicit booleans at their reference values.
125    fn default() -> Self {
126        Self {
127            rate_limit: None,
128            rate_limit_window: None,
129            ip_whitelist: None,
130            ip_blacklist: None,
131            blocked_countries: None,
132            whitelist_countries: None,
133            bypassed_checks: BTreeSet::new(),
134            require_https: false,
135            auth_required: None,
136            custom_validators: Vec::new(),
137            blocked_user_agents: Vec::new(),
138            required_headers: BTreeMap::new(),
139            behavior_rules: Vec::new(),
140            block_cloud_providers: BTreeSet::new(),
141            max_request_size: None,
142            allowed_content_types: None,
143            time_restrictions: None,
144            enable_suspicious_detection: true,
145            require_referrer: None,
146            api_key_required: false,
147            auth_verifier: None,
148            api_key_verifier: None,
149            api_key_header: None,
150            authorization_header_required: None,
151            geo_rate_limits: None,
152            excluded_detection_headers: None,
153            excluded_semantic_detection_headers: None,
154            excluded_detection_params: None,
155            excluded_detection_body_fields: None,
156            enabled_detection_categories: None,
157            detection_scan_body: None,
158        }
159    }
160}
161
162impl RouteConfig {
163    /// The rate-limit view (`rate_limit` + `rate_limit_window` +
164    /// `geo_rate_limits`): the carrier the engine's route-rate resolver
165    /// seam consumes, `None` when
166    /// the route sets no rate-limit override. `Err` on a zero limit or
167    /// window (the engine's validated constructor).
168    pub fn rate_limits(
169        &self,
170    ) -> Result<Option<RouteRateLimits>, crate::rate_limit::RateLimitConfigError> {
171        if self.rate_limit.is_none() && self.geo_rate_limits.is_none() {
172            return Ok(None);
173        }
174        let geo = self.geo_rate_limits.as_ref().map(|geo| {
175            geo.iter()
176                .map(|(country, (requests, window))| {
177                    (
178                        country.clone(),
179                        crate::rate_limit::RateLimitEntry {
180                            requests: *requests,
181                            window: *window,
182                        },
183                    )
184                })
185                .collect::<HashMap<_, _>>()
186        });
187        RouteRateLimits::new(self.rate_limit, self.rate_limit_window, geo).map(Some)
188    }
189
190    /// The detection view (the five exclusion/enablement knobs): the
191    /// carrier the [`crate::detection_exclusions`] seam consumes.
192    #[must_use]
193    pub fn detection_exclusions(&self) -> RouteDetectionExclusions {
194        RouteDetectionExclusions {
195            excluded_detection_headers: self
196                .excluded_detection_headers
197                .as_ref()
198                .map(|set| set.iter().cloned().collect()),
199            excluded_semantic_detection_headers: self
200                .excluded_semantic_detection_headers
201                .as_ref()
202                .map(|set| set.iter().cloned().collect()),
203            excluded_detection_params: self
204                .excluded_detection_params
205                .as_ref()
206                .map(|set| set.iter().cloned().collect()),
207            excluded_detection_body_fields: self
208                .excluded_detection_body_fields
209                .as_ref()
210                .map(|set| set.iter().cloned().collect()),
211            enabled_detection_categories: self
212                .enabled_detection_categories
213                .as_ref()
214                .map(|set| set.iter().cloned().collect()),
215            detection_scan_body: self.detection_scan_body,
216        }
217    }
218}
219
220/// How the adapter learns a route's config: `(method, path)` to the
221/// route override.
222///
223/// `None` when the route carries none (the global config applies). The
224/// reference `RouteConfigResolver` resolves the same tuple; the adapter's
225/// router is the authority on the match.
226pub type RouteConfigResolver = Arc<dyn Fn(&str, &str) -> Option<Arc<RouteConfig>> + Send + Sync>;
227
228/// The status of the strict-route-resolution rejection.
229pub const UNRESOLVED_ROUTE_STATUS: u16 = 500;
230
231/// The body of the strict-route-resolution rejection (the reference
232/// `create_error_response` default message).
233pub const UNRESOLVED_ROUTE_MESSAGE: &str = "Route resolution failed";
234
235/// The reason the unresolved-route log line and event carry
236/// (`UNRESOLVED_ROUTE_REASON`).
237pub const UNRESOLVED_ROUTE_REASON: &str =
238    "Route resolution failed; per-route decorator config could not be applied";
239
240/// The check name the rejection reports to the `on_block` hook
241/// (`RouteConfigCheck.check_name`).
242pub const UNRESOLVED_ROUTE_CHECK_NAME: &str = "route_config";
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    /// A default config with `mutate` applied (30 fields make literal
249    /// initializers unreadable).
250    fn with(mutate: impl FnOnce(&mut RouteConfig)) -> RouteConfig {
251        let mut config = RouteConfig::default();
252        mutate(&mut config);
253        config
254    }
255
256    #[test]
257    fn defaults_inherit_everything() {
258        let config = RouteConfig::default();
259        assert_eq!(config.rate_limit, None);
260        assert_eq!(config.rate_limit_window, None);
261        assert_eq!(config.ip_whitelist, None);
262        assert_eq!(config.ip_blacklist, None);
263        assert_eq!(config.blocked_countries, None);
264        assert_eq!(config.whitelist_countries, None);
265        assert!(config.bypassed_checks.is_empty());
266        assert!(!config.require_https);
267        assert_eq!(config.auth_required, None);
268        assert!(config.custom_validators.is_empty());
269        assert!(config.blocked_user_agents.is_empty());
270        assert!(config.required_headers.is_empty());
271        assert!(config.behavior_rules.is_empty());
272        assert!(config.block_cloud_providers.is_empty());
273        assert_eq!(config.max_request_size, None);
274        assert_eq!(config.allowed_content_types, None);
275        assert_eq!(config.time_restrictions, None);
276        assert!(config.enable_suspicious_detection);
277        assert_eq!(config.require_referrer, None);
278        assert!(!config.api_key_required);
279        assert!(config.auth_verifier.is_none());
280        assert!(config.api_key_verifier.is_none());
281        assert_eq!(config.api_key_header, None);
282        assert_eq!(config.authorization_header_required, None);
283        assert_eq!(config.geo_rate_limits, None);
284        assert_eq!(config.excluded_detection_headers, None);
285        assert_eq!(config.excluded_detection_params, None);
286        assert_eq!(config.excluded_detection_body_fields, None);
287        assert_eq!(config.enabled_detection_categories, None);
288        assert_eq!(config.detection_scan_body, None);
289    }
290
291    #[test]
292    fn the_rate_limit_view_carries_both_knobs_or_nothing() {
293        let config = RouteConfig {
294            rate_limit_window: Some(60),
295            ..RouteConfig::default()
296        };
297        // A window alone never configures a tier (the reference shape).
298        assert!(config.rate_limits().expect("valid").is_none());
299
300        let config = RouteConfig {
301            rate_limit: Some(5),
302            rate_limit_window: Some(60),
303            geo_rate_limits: Some(BTreeMap::from([(String::from("BR"), (10, 30))])),
304            ..RouteConfig::default()
305        };
306        let limits = config
307            .rate_limits()
308            .expect("valid")
309            .expect("tier configured");
310        assert_eq!(limits.rate_limit(), Some(5));
311        assert_eq!(limits.rate_limit_window(), Some(60));
312        assert_eq!(
313            limits.geo_rate_limits().map(std::collections::HashMap::len),
314            Some(1)
315        );
316    }
317
318    #[test]
319    fn the_detection_view_maps_the_five_knobs() {
320        let config = RouteConfig::default();
321        let view = config.detection_exclusions();
322        assert_eq!(view.excluded_detection_headers, None);
323        assert_eq!(view.excluded_detection_params, None);
324        assert_eq!(view.excluded_detection_body_fields, None);
325        assert_eq!(view.enabled_detection_categories, None);
326        assert_eq!(view.detection_scan_body, None);
327
328        let config = with(|config| {
329            config.excluded_detection_headers = Some(BTreeSet::from([String::from("x-api-key")]));
330            config.detection_scan_body = Some(false);
331        });
332        let view = config.detection_exclusions();
333        assert_eq!(
334            view.excluded_detection_headers,
335            Some(vec![String::from("x-api-key")])
336        );
337        assert_eq!(view.detection_scan_body, Some(false));
338    }
339
340    #[test]
341    fn a_zero_route_limit_is_rejected_by_the_engine_validator() {
342        let config = with(|config| config.rate_limit = Some(0));
343        assert!(config.rate_limits().is_err());
344    }
345
346    #[test]
347    fn the_resolver_seams_method_and_path() {
348        let resolver: RouteConfigResolver = Arc::new(|method, path| {
349            (method == "POST" && path == "/login").then(|| {
350                Arc::new(RouteConfig {
351                    rate_limit: Some(5),
352                    require_https: true,
353                    ..RouteConfig::default()
354                })
355            })
356        });
357        let config = resolver("POST", "/login").expect("route config");
358        assert_eq!(config.rate_limit, Some(5));
359        assert!(config.require_https);
360        assert!(resolver("GET", "/login").is_none());
361        assert!(resolver("POST", "/signup").is_none());
362    }
363}