Skip to main content

kdex_entitlements/
lib.rs

1use std::collections::HashMap;
2
3/// Represents a security scheme (e.g., "bearer", "oauth2").
4pub type SecurityScheme = String;
5
6/// A map of entitlements grouped by security scheme.
7pub type Entitlements = HashMap<SecurityScheme, Vec<String>>;
8
9/// A single requirement set (map of schemes to required patterns).
10pub type RequirementSet = HashMap<SecurityScheme, Vec<String>>;
11
12/// A list of alternative requirement sets (OR'd).
13pub type Requirements = Vec<RequirementSet>;
14
15/// A parsed representation of an entitlement or requirement pattern.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Pattern {
18    /// Long form: <resource>:<resourceName>:<verb>
19    Structured {
20        resource: String,
21        name: String,
22        verb: String,
23    },
24    /// Opaque form: <string>
25    Opaque(String),
26}
27
28impl Pattern {
29    /// Parses a pattern string into a Pattern enum.
30    pub fn parse(s: &str) -> Self {
31        let parts: Vec<&str> = s.split(':').collect();
32        match parts.len() {
33            3 => Self::Structured {
34                resource: parts[0].to_string(),
35                name: parts[1].to_string(),
36                verb: parts[2].to_string(),
37            },
38            2 => Self::Structured {
39                resource: parts[0].to_string(),
40                name: "*".to_string(),
41                verb: parts[1].to_string(),
42            },
43            _ => Self::Opaque(s.to_string()),
44        }
45    }
46
47    /// Checks if this pattern (as an entitlement) satisfies the required pattern.
48    pub fn satisfies(&self, required: &Pattern) -> bool {
49        match (self, required) {
50            (Self::Opaque(e), Self::Opaque(r)) => e == r,
51            (
52                Self::Structured {
53                    resource: er,
54                    name: en,
55                    verb: ev,
56                },
57                Self::Structured {
58                    resource: rr,
59                    name: rn,
60                    verb: rv,
61                },
62            ) => {
63                // Resource types must match
64                if er != rr {
65                    return false;
66                }
67
68                // Verb must match exactly or entitlement verb is "all"
69                if ev != rv && ev != "all" {
70                    return false;
71                }
72
73                // Name must match exactly, or either is a wildcard
74                if en != rn && en != "*" && !en.is_empty() && rn != "*" && !rn.is_empty() {
75                    return false;
76                }
77
78                true
79            }
80            // Mixed forms only match exactly if they are identical strings (unlikely given parse logic)
81            _ => false,
82        }
83    }
84}
85
86/// The main entitlements checker.
87pub struct EntitlementsChecker {
88    anonymous_entitlements: Vec<Pattern>,
89    base_entitlements: Vec<Pattern>,
90    default_scheme: String,
91}
92
93impl EntitlementsChecker {
94    pub fn new(anonymous_entitlements: Vec<String>, default_scheme: String) -> Self {
95        let parsed_anon = anonymous_entitlements.iter().map(|s| Pattern::parse(s)).collect();
96        Self {
97            anonymous_entitlements: parsed_anon,
98            base_entitlements: Vec::new(),
99            default_scheme,
100        }
101    }
102
103    /// Sets the base entitlements: patterns that apply to every caller
104    /// (authenticated or anonymous) under the default scheme. Unlike
105    /// `anonymous_entitlements` (which apply only when the caller's
106    /// entitlements map is empty), base entitlements form a floor of grants
107    /// that every request receives.
108    ///
109    /// Replaces any previously set base entitlements. Consuming-self
110    /// builder; intended for use during checker construction.
111    pub fn with_base_entitlements(mut self, patterns: Vec<String>) -> Self {
112        self.base_entitlements = patterns.iter().map(|s| Pattern::parse(s)).collect();
113        self
114    }
115
116    /// Verifies if the user's entitlements satisfy any of the requirements.
117    pub fn verify(&self, user_entitlements: &Entitlements, requirements: &Requirements) -> bool {
118        if requirements.is_empty() {
119            return true;
120        }
121
122        let parsed: HashMap<String, Vec<Pattern>> = user_entitlements
123            .iter()
124            .map(|(scheme, list)| (scheme.clone(), list.iter().map(|s| Pattern::parse(s)).collect()))
125            .collect();
126
127        let is_anonymous = parsed.is_empty() || parsed.values().all(|v| v.is_empty());
128
129        for req_set in requirements {
130            if self.verify_set(&parsed, req_set, is_anonymous) {
131                return true;
132            }
133        }
134
135        false
136    }
137
138    /// Checks that every (scheme, requirement-list) pair in `req_set` is satisfied.
139    /// Each requirement must be met by the caller's own entitlements, the base bag,
140    /// or (when `is_anonymous`) the anonymous bag. Returns false on the first
141    /// unsatisfied requirement (AND semantics across schemes and patterns).
142    fn verify_set(
143        &self,
144        user_patterns: &HashMap<String, Vec<Pattern>>,
145        req_set: &RequirementSet,
146        is_anonymous: bool,
147    ) -> bool {
148        for (scheme, required_patterns) in req_set {
149            let user_list_present = user_patterns.contains_key(scheme);
150            let has_fallback = scheme == &self.default_scheme
151                && (!self.base_entitlements.is_empty()
152                    || (is_anonymous && !self.anonymous_entitlements.is_empty()));
153            if !user_list_present && !has_fallback {
154                return false;
155            }
156
157            let empty: Vec<Pattern> = Vec::new();
158            let user_list = user_patterns.get(scheme).unwrap_or(&empty);
159
160            for req_str in required_patterns {
161                let req_p = Pattern::parse(req_str);
162                let satisfied_by_user = user_list.iter().any(|p| p.satisfies(&req_p));
163                let satisfied_by_base = scheme == &self.default_scheme
164                    && self.base_entitlements.iter().any(|p| p.satisfies(&req_p));
165                let satisfied_by_anon = scheme == &self.default_scheme
166                    && is_anonymous
167                    && self.anonymous_entitlements.iter().any(|p| p.satisfies(&req_p));
168                if !satisfied_by_user && !satisfied_by_base && !satisfied_by_anon {
169                    return false;
170                }
171            }
172        }
173        true
174    }
175
176    /// Verifies access for a specific resource instance.
177    pub fn verify_resource(
178        &self,
179        user_entitlements: &Entitlements,
180        resource: &str,
181        name: &str,
182        verb: &str,
183        additional_requirements: &Requirements,
184    ) -> bool {
185        let identity_req = format!("{}:{}:{}", resource, name, verb);
186
187        if additional_requirements.is_empty() {
188            let mut set = RequirementSet::new();
189            set.insert(self.default_scheme.clone(), vec![identity_req]);
190            return self.verify(user_entitlements, &vec![set]);
191        }
192
193        let mut combined_requirements = Vec::new();
194        for set in additional_requirements {
195            let mut new_set = set.clone();
196            new_set
197                .entry(self.default_scheme.clone())
198                .or_default()
199                .push(identity_req.clone());
200            combined_requirements.push(new_set);
201        }
202
203        self.verify(user_entitlements, &combined_requirements)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_pattern_parse() {
213        assert_eq!(
214            Pattern::parse("pages:/foo:read"),
215            Pattern::Structured {
216                resource: "pages".to_string(),
217                name: "/foo".to_string(),
218                verb: "read".to_string()
219            }
220        );
221        assert_eq!(
222            Pattern::parse("pages:read"),
223            Pattern::Structured {
224                resource: "pages".to_string(),
225                name: "*".to_string(),
226                verb: "read".to_string()
227            }
228        );
229        assert_eq!(Pattern::parse("admin"), Pattern::Opaque("admin".to_string()));
230    }
231
232    #[test]
233    fn test_verify() {
234        let checker = EntitlementsChecker::new(vec!["anonymous:read".to_string()], "bearer".to_string());
235        
236        let mut entitlements = Entitlements::new();
237        entitlements.insert("bearer".to_string(), vec!["pages:foo:read".to_string()]);
238        
239        // Simple match
240        let mut req_set = RequirementSet::new();
241        req_set.insert("bearer".to_string(), vec!["pages:foo:read".to_string()]);
242        let requirements = vec![req_set];
243        assert!(checker.verify(&entitlements, &requirements));
244        
245        // Wildcard match
246        let mut req_set = RequirementSet::new();
247        req_set.insert("bearer".to_string(), vec!["pages:*:read".to_string()]);
248        let requirements = vec![req_set];
249        assert!(checker.verify(&entitlements, &requirements));
250
251        // Authed caller does NOT get the anonymous bag (regression for issue #3)
252        let mut req_set = RequirementSet::new();
253        req_set.insert("bearer".to_string(), vec!["anonymous:*:read".to_string()]);
254        let requirements = vec![req_set];
255        assert!(!checker.verify(&entitlements, &requirements));
256
257        // Anonymous caller DOES get the anonymous bag
258        let empty_entitlements = Entitlements::new();
259        let mut req_set = RequirementSet::new();
260        req_set.insert("bearer".to_string(), vec!["anonymous:*:read".to_string()]);
261        let requirements = vec![req_set];
262        assert!(checker.verify(&empty_entitlements, &requirements));
263
264        // Failing match
265        let mut req_set = RequirementSet::new();
266        req_set.insert("bearer".to_string(), vec!["pages:foo:write".to_string()]);
267        let requirements = vec![req_set];
268        assert!(!checker.verify(&entitlements, &requirements));
269
270        // OR match
271        let mut req_set1 = RequirementSet::new();
272        req_set1.insert("bearer".to_string(), vec!["pages:foo:write".to_string()]);
273        let mut req_set2 = RequirementSet::new();
274        req_set2.insert("bearer".to_string(), vec!["pages:foo:read".to_string()]);
275        let requirements = vec![req_set1, req_set2];
276        assert!(checker.verify(&entitlements, &requirements));
277    }
278
279    #[test]
280    fn test_verify_resource() {
281        let checker = EntitlementsChecker::new(vec![], "bearer".to_string());
282        let mut entitlements = Entitlements::new();
283        entitlements.insert("bearer".to_string(), vec!["pages:foo:read".to_string(), "admin".to_string()]);
284
285        // Identity check
286        assert!(checker.verify_resource(&entitlements, "pages", "foo", "read", &vec![]));
287        assert!(!checker.verify_resource(&entitlements, "pages", "bar", "read", &vec![]));
288
289        // Identity + additional requirements
290        let mut req_set = RequirementSet::new();
291        req_set.insert("bearer".to_string(), vec!["admin".to_string()]);
292        let additional = vec![req_set];
293        assert!(checker.verify_resource(&entitlements, "pages", "foo", "read", &additional));
294
295        let mut req_set = RequirementSet::new();
296        req_set.insert("bearer".to_string(), vec!["superadmin".to_string()]);
297        let additional = vec![req_set];
298        assert!(!checker.verify_resource(&entitlements, "pages", "foo", "read", &additional));
299    }
300
301    #[test]
302    fn test_anonymous_vs_base() {
303        let checker = EntitlementsChecker::new(
304            vec!["anon:read".to_string()],
305            "bearer".to_string(),
306        )
307        .with_base_entitlements(vec!["base:read".to_string()]);
308
309        let mut authed = Entitlements::new();
310        authed.insert("bearer".to_string(), vec!["pages:foo:read".to_string()]);
311        let anonymous = Entitlements::new();
312
313        let need_anon = vec![{
314            let mut s = RequirementSet::new();
315            s.insert("bearer".to_string(), vec!["anon:read".to_string()]);
316            s
317        }];
318        let need_base = vec![{
319            let mut s = RequirementSet::new();
320            s.insert("bearer".to_string(), vec!["base:read".to_string()]);
321            s
322        }];
323
324        // Authed caller: base yes, anon no
325        assert!(checker.verify(&authed, &need_base));
326        assert!(!checker.verify(&authed, &need_anon));
327
328        // Anonymous caller: both
329        assert!(checker.verify(&anonymous, &need_base));
330        assert!(checker.verify(&anonymous, &need_anon));
331
332        // Authed caller with only an empty scheme list is still anonymous
333        let mut empty_list = Entitlements::new();
334        empty_list.insert("bearer".to_string(), vec![]);
335        assert!(checker.verify(&empty_list, &need_anon));
336
337        // Caller with entitlements in a different scheme is NOT anonymous
338        let mut other_scheme = Entitlements::new();
339        other_scheme.insert("oauth2".to_string(), vec!["scope1".to_string()]);
340        assert!(!checker.verify(&other_scheme, &need_anon));
341    }
342
343    #[test]
344    fn test_anonymous_vs_base_via_verify_resource() {
345        // Authed caller satisfies identity via base
346        let base_checker = EntitlementsChecker::new(vec![], "bearer".to_string())
347            .with_base_entitlements(vec!["pages:/foo:read".to_string()]);
348        let mut authed = Entitlements::new();
349        authed.insert("bearer".to_string(), vec!["other:read".to_string()]);
350        assert!(base_checker.verify_resource(&authed, "pages", "/foo", "read", &vec![]));
351
352        // Anonymous caller satisfies identity via base
353        let anonymous = Entitlements::new();
354        assert!(base_checker.verify_resource(&anonymous, "pages", "/foo", "read", &vec![]));
355
356        // Authed caller does NOT satisfy identity via anonymous bag
357        let anon_checker = EntitlementsChecker::new(
358            vec!["pages:/foo:read".to_string()],
359            "bearer".to_string(),
360        );
361        assert!(!anon_checker.verify_resource(&authed, "pages", "/foo", "read", &vec![]));
362
363        // Anonymous caller DOES satisfy identity via anonymous bag
364        assert!(anon_checker.verify_resource(&anonymous, "pages", "/foo", "read", &vec![]));
365    }
366
367    #[test]
368    fn test_with_base_entitlements_replaces() {
369        let checker = EntitlementsChecker::new(vec![], "bearer".to_string())
370            .with_base_entitlements(vec!["first:read".to_string()])
371            .with_base_entitlements(vec!["second:read".to_string()]);
372
373        let mut authed = Entitlements::new();
374        authed.insert("bearer".to_string(), vec!["pages:foo:read".to_string()]);
375
376        let need_first = vec![{
377            let mut s = RequirementSet::new();
378            s.insert("bearer".to_string(), vec!["first:read".to_string()]);
379            s
380        }];
381        let need_second = vec![{
382            let mut s = RequirementSet::new();
383            s.insert("bearer".to_string(), vec!["second:read".to_string()]);
384            s
385        }];
386
387        assert!(!checker.verify(&authed, &need_first));
388        assert!(checker.verify(&authed, &need_second));
389    }
390}