1use 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#[derive(Clone, Debug)]
20pub struct ScopeEnforcementRules {
21 rules: Arc<[CompiledRule]>,
23 enabled: bool,
25}
26
27#[derive(Clone, Debug)]
29struct CompiledRule {
30 pattern: Pattern,
31 method: Option<String>,
33 required_scopes: Vec<String>,
34}
35
36impl ScopeEnforcementRules {
37 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 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 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 #[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 if token_scopes.iter().any(|s| s == "*") {
123 return Ok(());
124 }
125
126 let match_opts = MatchOptions {
128 require_literal_separator: true,
129 ..MatchOptions::default()
130 };
131
132 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 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 Ok(())
172 }
173}
174
175#[derive(Clone)]
177pub struct ScopeEnforcementState {
178 pub rules: ScopeEnforcementRules,
179}
180
181pub 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 if !state.rules.enabled {
194 return next.run(req).await;
195 }
196
197 let path = req.uri().path();
200 let path = common::resolve_path(&req, path);
201 let method = req.method().as_str();
202
203 let Some(security_context) = req.extensions().get::<SecurityContext>() else {
205 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 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 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 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 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 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 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 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 assert!(rules.check("/api/v1/items", "GET", &scopes).is_ok());
353 assert!(rules.check("/api/v2/items", "GET", &scopes).is_ok());
354
355 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 ); }
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 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 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 let admin_scopes = vec!["admin".to_owned()];
413 assert!(rules.check("/admin/users", "GET", &admin_scopes).is_ok());
414
415 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 assert!(rules.check("/admin/users", "GET", &events_scopes).is_err());
425
426 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 let config = build_config(
439 true,
440 vec![
441 ("/api/**", vec!["basic"]), ("/api/admin/**", vec!["admin"]), ],
444 );
445 let rules = ScopeEnforcementRules::from_config(&config).unwrap();
446
447 let basic_scopes = vec!["basic".to_owned()];
449 let admin_scopes = vec!["admin".to_owned()];
450
451 assert!(
453 rules
454 .check("/api/admin/users", "GET", &basic_scopes)
455 .is_ok()
456 );
457
458 assert!(
461 rules
462 .check("/api/admin/users", "GET", &admin_scopes)
463 .is_err()
464 );
465
466 }
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 assert!(!rules.matches_protected_route("/admin/users", "GET"));
495 }
496
497 #[test]
498 fn first_match_wins_more_specific_rule_first() {
499 let config = build_config(
501 true,
502 vec![
503 ("/api/admin/**", vec!["admin"]), ("/api/**", vec!["basic"]), ],
506 );
507 let rules = ScopeEnforcementRules::from_config(&config).unwrap();
508
509 let admin_scopes = vec!["admin".to_owned()];
511 let basic_scopes = vec!["basic".to_owned()];
512
513 assert!(
515 rules
516 .check("/api/admin/users", "GET", &admin_scopes)
517 .is_ok()
518 );
519
520 assert!(
522 rules
523 .check("/api/admin/users", "GET", &basic_scopes)
524 .is_err()
525 );
526
527 assert!(rules.check("/api/other", "GET", &basic_scopes).is_ok());
529 }
530
531 #[test]
532 fn first_match_wins_broader_rule_first() {
533 let config = build_config(
535 true,
536 vec![
537 ("/api/**", vec!["basic"]), ("/api/admin/**", vec!["admin"]), ],
540 );
541 let rules = ScopeEnforcementRules::from_config(&config).unwrap();
542
543 let basic_scopes = vec!["basic".to_owned()];
544
545 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 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 assert!(rules.check("/users/123", "GET", &read_scopes).is_ok());
570
571 assert!(rules.check("/users/123", "POST", &write_scopes).is_ok());
573
574 assert!(rules.check("/users/123", "GET", &write_scopes).is_err());
577
578 assert!(rules.check("/users/123", "POST", &read_scopes).is_err());
580 }
581
582 #[test]
583 fn method_matching_any_method() {
584 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 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 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 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}