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