Skip to main content

structured_proxy/auth/
policy.rs

1//! Route-level access policies.
2//!
3//! Each policy matches requests by path glob and HTTP method, and declares
4//! whether authentication is required and which roles the caller must hold.
5
6use globset::GlobMatcher;
7
8use crate::config::RoutePolicyConfig;
9
10/// A compiled route policy.
11pub struct RoutePolicy {
12    matcher: GlobMatcher,
13    methods: Vec<String>,
14    /// Whether a valid token is required to reach the route.
15    pub require_auth: bool,
16    /// Roles the caller must all hold (AND semantics).
17    pub required_roles: Vec<String>,
18}
19
20impl RoutePolicy {
21    fn matches_method(&self, method: &str) -> bool {
22        self.methods.iter().any(|m| m == "*" || m == method)
23    }
24}
25
26/// A compiled set of route policies, matched in declaration order.
27#[derive(Default)]
28pub struct Policies {
29    rules: Vec<RoutePolicy>,
30}
31
32impl Policies {
33    /// Compile policy config; the first matching rule wins at request time.
34    ///
35    /// # Errors
36    /// Returns an error when a path glob fails to compile.
37    pub fn compile(configs: &[RoutePolicyConfig]) -> Result<Self, String> {
38        let rules = configs
39            .iter()
40            .map(|c| {
41                let matcher = globset::GlobBuilder::new(&c.path)
42                    .literal_separator(true)
43                    .build()
44                    .map(|g| g.compile_matcher())
45                    .map_err(|e| format!("invalid policy path {:?}: {e}", c.path))?;
46                Ok(RoutePolicy {
47                    matcher,
48                    methods: c.methods.iter().map(|m| m.to_uppercase()).collect(),
49                    require_auth: c.require_auth,
50                    required_roles: c.required_roles.clone(),
51                })
52            })
53            .collect::<Result<Vec<_>, String>>()?;
54        Ok(Self { rules })
55    }
56
57    /// First policy matching `path` and `method` (method already uppercased).
58    pub fn match_rule(&self, path: &str, method: &str) -> Option<&RoutePolicy> {
59        self.rules
60            .iter()
61            .find(|r| r.matcher.is_match(path) && r.matches_method(method))
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    fn policy(
70        path: &str,
71        methods: &[&str],
72        require_auth: bool,
73        roles: &[&str],
74    ) -> RoutePolicyConfig {
75        RoutePolicyConfig {
76            path: path.to_string(),
77            methods: methods.iter().map(|s| s.to_string()).collect(),
78            require_auth,
79            required_roles: roles.iter().map(|s| s.to_string()).collect(),
80        }
81    }
82
83    #[test]
84    fn matches_path_and_method() {
85        let p = Policies::compile(&[policy("/v1/admin/**", &["GET", "POST"], true, &["admin"])])
86            .unwrap();
87        assert!(p.match_rule("/v1/admin/users", "GET").is_some());
88        assert!(p.match_rule("/v1/admin/users", "POST").is_some());
89        // Method not listed → no match.
90        assert!(p.match_rule("/v1/admin/users", "DELETE").is_none());
91        // Path outside the glob → no match.
92        assert!(p.match_rule("/v1/public", "GET").is_none());
93    }
94
95    #[test]
96    fn wildcard_method_matches_any() {
97        let p = Policies::compile(&[policy("/v1/**", &["*"], true, &[])]).unwrap();
98        assert!(p.match_rule("/v1/x", "PATCH").is_some());
99    }
100
101    #[test]
102    fn first_matching_rule_wins() {
103        let p = Policies::compile(&[
104            policy("/v1/health", &["*"], false, &[]),
105            policy("/v1/**", &["*"], true, &["admin"]),
106        ])
107        .unwrap();
108        // Health rule comes first and does not require auth.
109        assert!(!p.match_rule("/v1/health", "GET").unwrap().require_auth);
110        // Everything else falls to the catch-all.
111        assert!(p.match_rule("/v1/secret", "GET").unwrap().require_auth);
112    }
113}