Skip to main content

api_gateway/middleware/
scope_enforcement.rs

1//! Gateway Scope Enforcement Middleware
2//!
3//! Performs coarse-grained early rejection of requests based on token scopes
4//! without calling the PDP. This is an optimization for performance-critical routes.
5//!
6//! See `docs/arch/authorization/DESIGN.md` section "Gateway Scope Enforcement" for details.
7
8use std::sync::Arc;
9
10use axum::response::IntoResponse;
11use glob::{MatchOptions, Pattern};
12
13use crate::config::RoutePoliciesConfig;
14use crate::middleware::common;
15use modkit::api::Problem;
16use modkit_security::SecurityContext;
17
18/// Compiled scope enforcement rules for efficient runtime matching.
19#[derive(Clone, Debug)]
20pub struct ScopeEnforcementRules {
21    /// Compiled glob patterns with their required scopes.
22    rules: Arc<[CompiledRule]>,
23    /// Whether scope enforcement is enabled.
24    enabled: bool,
25}
26
27/// A single compiled rule: glob pattern + optional method + required scopes.
28#[derive(Clone, Debug)]
29struct CompiledRule {
30    pattern: Pattern,
31    /// HTTP method to match (uppercase). None means match any method.
32    method: Option<String>,
33    required_scopes: Vec<String>,
34}
35
36impl ScopeEnforcementRules {
37    /// Build scope enforcement rules from configuration.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if any glob pattern is invalid or if any rule has empty `required_scopes`.
42    pub fn from_config(config: &RoutePoliciesConfig) -> Result<Self, anyhow::Error> {
43        if !config.enabled {
44            return Ok(Self {
45                rules: Arc::from([]),
46                enabled: false,
47            });
48        }
49
50        let mut rules = Vec::with_capacity(config.rules.len());
51
52        for rule in &config.rules {
53            // Validate: empty required_scopes is likely a config mistake
54            if rule.required_scopes.is_empty() {
55                return Err(anyhow::anyhow!(
56                    "Route policy rule for path '{}' has empty required_scopes. \
57                     This would match all tokens and is likely a config mistake.",
58                    rule.path
59                ));
60            }
61
62            let pattern = Pattern::new(&rule.path).map_err(|e| {
63                anyhow::anyhow!(
64                    "Invalid glob pattern '{}' in route_policies: {e}",
65                    rule.path
66                )
67            })?;
68
69            rules.push(CompiledRule {
70                pattern,
71                method: rule.method.as_ref().map(|m| m.to_uppercase()),
72                required_scopes: rule.required_scopes.clone(),
73            });
74        }
75
76        tracing::info!(
77            rules_count = rules.len(),
78            "Route policy enforcement enabled with {} rules",
79            rules.len()
80        );
81
82        Ok(Self {
83            rules: Arc::from(rules),
84            enabled: true,
85        })
86    }
87
88    /// Check if the given path and method match any protected route.
89    ///
90    /// Returns `true` if the path/method matches at least one scope enforcement rule.
91    fn matches_protected_route(&self, path: &str, method: &str) -> bool {
92        if !self.enabled {
93            return false;
94        }
95
96        let match_opts = MatchOptions {
97            require_literal_separator: true,
98            ..MatchOptions::default()
99        };
100
101        self.rules.iter().any(|rule| {
102            let path_matches = rule.pattern.matches_with(path, match_opts);
103            let method_matches = rule
104                .method
105                .as_ref()
106                .is_none_or(|m| m.eq_ignore_ascii_case(method));
107            path_matches && method_matches
108        })
109    }
110
111    /// Check if the given path, method, and token scopes satisfy the scope requirements.
112    ///
113    /// Returns `Ok(())` if access is allowed, or `Err(problem)` if denied.
114    #[allow(clippy::result_large_err)]
115    fn check(&self, path: &str, method: &str, token_scopes: &[String]) -> Result<(), Problem> {
116        if !self.enabled {
117            return Ok(());
118        }
119
120        // Only wildcard scope `["*"]` is unrestricted (first-party apps).
121        // Empty scopes = no permissions (fail-closed).
122        if token_scopes.iter().any(|s| s == "*") {
123            return Ok(());
124        }
125
126        // Match options: require `/` to be matched literally so `*` doesn't cross path segments
127        let match_opts = MatchOptions {
128            require_literal_separator: true,
129            ..MatchOptions::default()
130        };
131
132        // First match wins: find the first matching rule and check scopes against it only.
133        // This allows more specific rules to override broader ones when declared first.
134        for rule in self.rules.iter() {
135            let path_matches = rule.pattern.matches_with(path, match_opts);
136            let method_matches = rule
137                .method
138                .as_ref()
139                .is_none_or(|m| m.eq_ignore_ascii_case(method));
140
141            if path_matches && method_matches {
142                // Check if token has ANY of the required scopes
143                let has_required_scope = rule
144                    .required_scopes
145                    .iter()
146                    .any(|required| token_scopes.contains(required));
147
148                if has_required_scope {
149                    return Ok(());
150                }
151
152                tracing::warn!(
153                    path = %path,
154                    method = %method,
155                    pattern = %rule.pattern,
156                    rule_method = ?rule.method,
157                    required_scopes = ?rule.required_scopes,
158                    token_scopes = ?token_scopes,
159                    "Route policy enforcement denied: insufficient scopes"
160                );
161
162                return Err(Problem::new(
163                    axum::http::StatusCode::FORBIDDEN,
164                    "Forbidden",
165                    "Insufficient token scopes for this resource",
166                ));
167            }
168        }
169
170        // No rule matched — allow (unprotected route)
171        Ok(())
172    }
173}
174
175/// Scope enforcement middleware state.
176#[derive(Clone)]
177pub struct ScopeEnforcementState {
178    pub rules: ScopeEnforcementRules,
179}
180
181/// Scope enforcement middleware.
182///
183/// Checks if the request's token scopes satisfy the configured requirements
184/// for the matched route pattern. Returns 403 Forbidden if scopes are insufficient.
185///
186/// This middleware MUST run AFTER the auth middleware (which populates `SecurityContext`).
187pub async fn scope_enforcement_middleware(
188    axum::extract::State(state): axum::extract::State<ScopeEnforcementState>,
189    req: axum::extract::Request,
190    next: axum::middleware::Next,
191) -> axum::response::Response {
192    // Skip if enforcement is disabled
193    if !state.rules.enabled {
194        return next.run(req).await;
195    }
196
197    // Use the concrete URI path for glob pattern matching (not MatchedPath which
198    // returns the route template like "/{*path}" for catch-all routes).
199    let path = req.uri().path();
200    let path = common::resolve_path(&req, path);
201    let method = req.method().as_str();
202
203    // Get SecurityContext from request extensions (populated by auth middleware)
204    let Some(security_context) = req.extensions().get::<SecurityContext>() else {
205        // No SecurityContext means auth middleware didn't run or request is unauthenticated.
206        // If the path matches a protected route, reject with 401 Unauthorized.
207        // Otherwise, let it pass through for public/unprotected routes.
208        if state.rules.matches_protected_route(&path, method) {
209            tracing::warn!(
210                path = %path,
211                method = %method,
212                "Route policy enforcement denied: no SecurityContext for protected route"
213            );
214            return Problem::new(
215                axum::http::StatusCode::UNAUTHORIZED,
216                "Unauthorized",
217                "Authentication required for this resource",
218            )
219            .into_response();
220        }
221        return next.run(req).await;
222    };
223
224    // Check scopes
225    if let Err(problem) = state
226        .rules
227        .check(&path, method, security_context.token_scopes())
228    {
229        return problem.into_response();
230    }
231
232    next.run(req).await
233}
234
235#[cfg(test)]
236#[cfg_attr(coverage_nightly, coverage(off))]
237mod tests {
238    use super::*;
239    use crate::config::RoutePolicyRule;
240
241    fn build_config(enabled: bool, routes: Vec<(&str, Vec<&str>)>) -> RoutePoliciesConfig {
242        build_config_with_methods(
243            enabled,
244            routes.into_iter().map(|(p, s)| (p, None, s)).collect(),
245        )
246    }
247
248    type TestRoute<'a> = (&'a str, Option<&'a str>, Vec<&'a str>);
249
250    fn build_config_with_methods(enabled: bool, routes: Vec<TestRoute<'_>>) -> RoutePoliciesConfig {
251        let rules = routes
252            .into_iter()
253            .map(|(path, method, scopes)| RoutePolicyRule {
254                path: path.to_owned(),
255                method: method.map(String::from),
256                required_scopes: scopes.into_iter().map(String::from).collect(),
257            })
258            .collect();
259
260        RoutePoliciesConfig { enabled, rules }
261    }
262
263    #[test]
264    fn disabled_enforcement_always_passes() {
265        let config = build_config(false, vec![("/admin/*", vec!["admin"])]);
266        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
267
268        // Even with no scopes, should pass when disabled
269        assert!(rules.check("/admin/users", "GET", &[]).is_ok());
270    }
271
272    #[test]
273    fn first_party_app_always_passes() {
274        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
275        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
276
277        // First-party apps have ["*"] scope
278        let scopes = vec!["*".to_owned()];
279        assert!(rules.check("/admin/users", "GET", &scopes).is_ok());
280    }
281
282    #[test]
283    fn matching_scope_passes() {
284        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
285        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
286
287        let scopes = vec!["admin".to_owned()];
288        assert!(rules.check("/admin/users", "GET", &scopes).is_ok());
289    }
290
291    #[test]
292    fn any_of_required_scopes_passes() {
293        let config = build_config(
294            true,
295            vec![("/events/v1/*", vec!["read:events", "write:events"])],
296        );
297        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
298
299        // Having just one of the required scopes should pass
300        let scopes = vec!["read:events".to_owned()];
301        assert!(rules.check("/events/v1/list", "GET", &scopes).is_ok());
302
303        let scopes = vec!["write:events".to_owned()];
304        assert!(rules.check("/events/v1/create", "POST", &scopes).is_ok());
305    }
306
307    #[test]
308    fn missing_scope_returns_forbidden() {
309        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
310        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
311
312        // No matching scope
313        let scopes = vec!["read:events".to_owned()];
314        let result = rules.check("/admin/users", "GET", &scopes);
315        assert!(result.is_err());
316
317        let problem = result.unwrap_err();
318        assert_eq!(problem.status, axum::http::StatusCode::FORBIDDEN);
319    }
320
321    #[test]
322    fn empty_scopes_returns_forbidden() {
323        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
324        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
325
326        // Empty scopes = no permissions (fail-closed)
327        let result = rules.check("/admin/users", "GET", &[]);
328        assert!(result.is_err());
329
330        let problem = result.unwrap_err();
331        assert_eq!(problem.status, axum::http::StatusCode::FORBIDDEN);
332    }
333
334    #[test]
335    fn unmatched_route_passes() {
336        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
337        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
338
339        // Route doesn't match any pattern, should pass even with unrelated scope
340        let scopes = vec!["unrelated:scope".to_owned()];
341        assert!(rules.check("/public/health", "GET", &scopes).is_ok());
342    }
343
344    #[test]
345    fn glob_single_star_matches_single_segment_only() {
346        let config = build_config(true, vec![("/api/*/items", vec!["api:read"])]);
347        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
348
349        let scopes = vec!["api:read".to_owned()];
350
351        // Single * matches exactly one path segment (doesn't cross `/`)
352        assert!(rules.check("/api/v1/items", "GET", &scopes).is_ok());
353        assert!(rules.check("/api/v2/items", "GET", &scopes).is_ok());
354
355        // Multiple segments do NOT match single * pattern (no scope check triggered)
356        let unrelated_scopes = vec!["unrelated:scope".to_owned()];
357        assert!(
358            rules
359                .check("/api/v1/nested/items", "GET", &unrelated_scopes)
360                .is_ok()
361        ); // doesn't match pattern
362    }
363
364    #[test]
365    fn glob_double_star_matches_multiple_segments() {
366        let config = build_config(true, vec![("/api/**", vec!["api:access"])]);
367        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
368
369        let scopes = vec!["api:access".to_owned()];
370
371        // ** matches any number of path segments
372        assert!(rules.check("/api/v1", "GET", &scopes).is_ok());
373        assert!(rules.check("/api/v1/items", "GET", &scopes).is_ok());
374        assert!(
375            rules
376                .check("/api/v1/items/123/details", "GET", &scopes)
377                .is_ok()
378        );
379    }
380
381    #[test]
382    fn invalid_glob_pattern_returns_error() {
383        let config = build_config(true, vec![("/admin/[invalid", vec!["admin"])]);
384        let result = ScopeEnforcementRules::from_config(&config);
385        assert!(result.is_err());
386    }
387
388    #[test]
389    fn empty_required_scopes_returns_error() {
390        let config = build_config(true, vec![("/admin/*", vec![])]);
391        let result = ScopeEnforcementRules::from_config(&config);
392        let err = result.expect_err("should fail with empty required_scopes");
393        assert!(
394            err.to_string().contains("empty required_scopes"),
395            "error should mention empty required_scopes: {err}"
396        );
397    }
398
399    #[test]
400    fn multiple_non_overlapping_rules() {
401        // Non-overlapping patterns: each path matches exactly one rule
402        let config = build_config(
403            true,
404            vec![
405                ("/admin/*", vec!["admin"]),
406                ("/events/**", vec!["events:read"]),
407            ],
408        );
409        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
410
411        // Admin route needs admin scope
412        let admin_scopes = vec!["admin".to_owned()];
413        assert!(rules.check("/admin/users", "GET", &admin_scopes).is_ok());
414
415        // Events route needs events:read scope
416        let events_scopes = vec!["events:read".to_owned()];
417        assert!(
418            rules
419                .check("/events/v1/list", "GET", &events_scopes)
420                .is_ok()
421        );
422
423        // Wrong scope for admin route
424        assert!(rules.check("/admin/users", "GET", &events_scopes).is_err());
425
426        // Wrong scope for events route
427        assert!(
428            rules
429                .check("/events/v1/list", "GET", &admin_scopes)
430                .is_err()
431        );
432    }
433
434    #[test]
435    fn overlapping_rules_first_match_wins() {
436        // Path /api/admin/users matches BOTH rules with DIFFERENT scope requirements.
437        // First-match-wins: only the first matching rule is evaluated.
438        let config = build_config(
439            true,
440            vec![
441                ("/api/**", vec!["basic"]), // Matches /api/admin/users, requires "basic"
442                ("/api/admin/**", vec!["admin"]), // Also matches, requires "admin" (never evaluated)
443            ],
444        );
445        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
446
447        // /api/admin/users matches both rules, but first rule wins
448        let basic_scopes = vec!["basic".to_owned()];
449        let admin_scopes = vec!["admin".to_owned()];
450
451        // "basic" scope passes because first rule (/api/**) is evaluated
452        assert!(
453            rules
454                .check("/api/admin/users", "GET", &basic_scopes)
455                .is_ok()
456        );
457
458        // "admin" scope also passes (it satisfies the first rule too? No - let's check)
459        // Actually "admin" does NOT satisfy ["basic"], so it should fail
460        assert!(
461            rules
462                .check("/api/admin/users", "GET", &admin_scopes)
463                .is_err()
464        );
465
466        // This demonstrates first-match-wins: even though second rule requires "admin",
467        // the first rule requiring "basic" takes precedence for /api/admin/users
468    }
469
470    #[test]
471    fn matches_protected_route_returns_true_for_matching_path() {
472        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
473        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
474
475        assert!(rules.matches_protected_route("/admin/users", "GET"));
476        assert!(rules.matches_protected_route("/admin/settings", "POST"));
477    }
478
479    #[test]
480    fn matches_protected_route_returns_false_for_non_matching_path() {
481        let config = build_config(true, vec![("/admin/*", vec!["admin"])]);
482        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
483
484        assert!(!rules.matches_protected_route("/public/health", "GET"));
485        assert!(!rules.matches_protected_route("/api/v1/users", "GET"));
486    }
487
488    #[test]
489    fn matches_protected_route_returns_false_when_disabled() {
490        let config = build_config(false, vec![("/admin/*", vec!["admin"])]);
491        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
492
493        // Even matching paths return false when enforcement is disabled
494        assert!(!rules.matches_protected_route("/admin/users", "GET"));
495    }
496
497    #[test]
498    fn first_match_wins_more_specific_rule_first() {
499        // More specific rule declared first should take precedence
500        let config = build_config(
501            true,
502            vec![
503                ("/api/admin/**", vec!["admin"]), // More specific, declared first
504                ("/api/**", vec!["basic"]),       // Broader, declared second
505            ],
506        );
507        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
508
509        // /api/admin/users matches first rule, requires "admin"
510        let admin_scopes = vec!["admin".to_owned()];
511        let basic_scopes = vec!["basic".to_owned()];
512
513        // Admin scope passes (matches first rule)
514        assert!(
515            rules
516                .check("/api/admin/users", "GET", &admin_scopes)
517                .is_ok()
518        );
519
520        // Basic scope fails for /api/admin/users (first rule requires admin)
521        assert!(
522            rules
523                .check("/api/admin/users", "GET", &basic_scopes)
524                .is_err()
525        );
526
527        // Basic scope passes for /api/other (matches second rule)
528        assert!(rules.check("/api/other", "GET", &basic_scopes).is_ok());
529    }
530
531    #[test]
532    fn first_match_wins_broader_rule_first() {
533        // If broader rule is declared first, it takes precedence
534        let config = build_config(
535            true,
536            vec![
537                ("/api/**", vec!["basic"]),       // Broader, declared first
538                ("/api/admin/**", vec!["admin"]), // More specific, declared second (never reached)
539            ],
540        );
541        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
542
543        let basic_scopes = vec!["basic".to_owned()];
544
545        // /api/admin/users matches first rule (/api/**), so "basic" is sufficient
546        assert!(
547            rules
548                .check("/api/admin/users", "GET", &basic_scopes)
549                .is_ok()
550        );
551    }
552
553    #[test]
554    fn method_matching_specific_method() {
555        // Rule with specific method only matches that method
556        let config = build_config_with_methods(
557            true,
558            vec![
559                ("/users/*", Some("POST"), vec!["users:write"]),
560                ("/users/*", Some("GET"), vec!["users:read"]),
561            ],
562        );
563        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
564
565        let read_scopes = vec!["users:read".to_owned()];
566        let write_scopes = vec!["users:write".to_owned()];
567
568        // GET with read scope passes
569        assert!(rules.check("/users/123", "GET", &read_scopes).is_ok());
570
571        // POST with write scope passes
572        assert!(rules.check("/users/123", "POST", &write_scopes).is_ok());
573
574        // GET with write scope fails (first matching rule requires users:write for POST)
575        // Actually GET matches second rule which requires users:read
576        assert!(rules.check("/users/123", "GET", &write_scopes).is_err());
577
578        // POST with read scope fails (POST rule requires users:write)
579        assert!(rules.check("/users/123", "POST", &read_scopes).is_err());
580    }
581
582    #[test]
583    fn method_matching_any_method() {
584        // Rule without method matches any method
585        let config = build_config_with_methods(true, vec![("/api/**", None, vec!["api:access"])]);
586        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
587
588        let scopes = vec!["api:access".to_owned()];
589
590        // All methods should match
591        assert!(rules.check("/api/users", "GET", &scopes).is_ok());
592        assert!(rules.check("/api/users", "POST", &scopes).is_ok());
593        assert!(rules.check("/api/users", "PUT", &scopes).is_ok());
594        assert!(rules.check("/api/users", "DELETE", &scopes).is_ok());
595    }
596
597    #[test]
598    fn method_matching_case_insensitive() {
599        // Method matching should be case-insensitive
600        let config =
601            build_config_with_methods(true, vec![("/api/**", Some("get"), vec!["api:read"])]);
602        let rules = ScopeEnforcementRules::from_config(&config).unwrap();
603
604        let scopes = vec!["api:read".to_owned()];
605
606        // Should match regardless of case
607        assert!(rules.check("/api/users", "GET", &scopes).is_ok());
608        assert!(rules.check("/api/users", "get", &scopes).is_ok());
609        assert!(rules.check("/api/users", "Get", &scopes).is_ok());
610    }
611}