Skip to main content

helios_auth/scope/
mod.rs

1pub mod permissions;
2pub mod smart_v2;
3
4pub use permissions::SmartPermissions;
5pub use smart_v2::{ResourceTypeSpec, ScopeContext, SmartScope};
6
7/// A set of parsed SMART v2 scopes from a JWT token.
8///
9/// Retains the original raw scope tokens alongside the parsed resource scopes so
10/// that *operation* scopes (e.g. `system/bulk-submit`), which do not match the
11/// `context/resourceType.permissions` grammar and are therefore dropped by
12/// [`SmartScope::parse`], can still be inspected — see [`ScopeSet::grants_operation`].
13#[derive(Debug, Clone, Default)]
14pub struct ScopeSet {
15    scopes: Vec<SmartScope>,
16    raw: Vec<String>,
17}
18
19impl ScopeSet {
20    /// Create an empty scope set.
21    pub fn empty() -> Self {
22        Self {
23            scopes: Vec::new(),
24            raw: Vec::new(),
25        }
26    }
27
28    /// Parse a space-delimited scope string (from JWT `scope` claim).
29    ///
30    /// Non-SMART scopes (e.g., `openid`, `profile`) are silently ignored for
31    /// resource-permission checks but retained in [`ScopeSet::raw`].
32    pub fn parse(scope_str: &str) -> Self {
33        let raw: Vec<String> = scope_str.split_whitespace().map(str::to_string).collect();
34        let scopes = raw.iter().filter_map(|s| SmartScope::parse(s)).collect();
35        Self { scopes, raw }
36    }
37
38    /// Parse from an array of scope strings (from JWT `scp` claim, e.g., Okta).
39    pub fn parse_array(scope_strs: &[String]) -> Self {
40        let raw: Vec<String> = scope_strs.to_vec();
41        let scopes = raw.iter().filter_map(|s| SmartScope::parse(s)).collect();
42        Self { scopes, raw }
43    }
44
45    /// Check if any scope grants the given permission on the given resource type.
46    pub fn is_permitted(&self, resource_type: &str, permission: SmartPermissions) -> bool {
47        self.scopes
48            .iter()
49            .any(|scope| scope.permits(resource_type, permission))
50    }
51
52    /// Returns the parsed scopes.
53    pub fn scopes(&self) -> &[SmartScope] {
54        &self.scopes
55    }
56
57    /// Returns the raw, unparsed scope tokens exactly as presented in the token.
58    pub fn raw(&self) -> &[String] {
59        &self.raw
60    }
61
62    /// Returns true if no SMART scopes were parsed.
63    pub fn is_empty(&self) -> bool {
64        self.scopes.is_empty()
65    }
66
67    /// Returns true if any parsed scope is a system-level wildcard (`system/*.<perms>`).
68    ///
69    /// Used as the ownership-bypass / broad-grant signal for bulk operations.
70    pub fn has_system_wildcard(&self) -> bool {
71        self.scopes.iter().any(|s| {
72            s.context == ScopeContext::System
73                && matches!(s.resource_type, ResourceTypeSpec::Wildcard)
74        })
75    }
76
77    /// Returns true if the token grants the named system-level *operation* scope.
78    ///
79    /// Matches the literal raw scope `system/{name}` (e.g. `system/bulk-submit`)
80    /// or any system-level wildcard scope (`system/*.<perms>`), which is treated
81    /// as granting all operations.
82    pub fn grants_operation(&self, name: &str) -> bool {
83        let target = format!("system/{name}");
84        self.raw.iter().any(|s| s == &target) || self.has_system_wildcard()
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_parse_space_delimited() {
94        let set = ScopeSet::parse("system/Patient.rs system/Observation.r openid profile");
95        assert_eq!(set.scopes().len(), 2);
96        assert!(set.is_permitted("Patient", SmartPermissions::READ));
97        assert!(set.is_permitted("Patient", SmartPermissions::SEARCH));
98        assert!(set.is_permitted("Observation", SmartPermissions::READ));
99        assert!(!set.is_permitted("Observation", SmartPermissions::SEARCH));
100    }
101
102    #[test]
103    fn test_parse_array() {
104        let scopes = vec![
105            "system/Patient.rs".to_string(),
106            "system/*.crud".to_string(),
107            "openid".to_string(),
108        ];
109        let set = ScopeSet::parse_array(&scopes);
110        assert_eq!(set.scopes().len(), 2);
111        assert!(set.is_permitted("Patient", SmartPermissions::READ));
112        // Wildcard scope grants CRUD on everything
113        assert!(set.is_permitted("Condition", SmartPermissions::CREATE));
114    }
115
116    #[test]
117    fn test_empty_scope() {
118        let set = ScopeSet::parse("");
119        assert!(set.is_empty());
120        assert!(!set.is_permitted("Patient", SmartPermissions::READ));
121    }
122
123    #[test]
124    fn test_wildcard_scope() {
125        let set = ScopeSet::parse("system/*.cruds");
126        assert!(set.is_permitted("Patient", SmartPermissions::CREATE));
127        assert!(set.is_permitted("Observation", SmartPermissions::DELETE));
128        assert!(set.is_permitted("Condition", SmartPermissions::SEARCH));
129    }
130
131    #[test]
132    fn test_non_smart_scopes_ignored() {
133        let set = ScopeSet::parse("openid profile email launch/patient");
134        assert!(set.is_empty());
135    }
136
137    #[test]
138    fn test_grants_operation_literal() {
139        let set = ScopeSet::parse("openid system/bulk-submit profile");
140        // Operation scope is dropped from parsed resource scopes but retained raw.
141        assert!(set.is_empty());
142        assert!(set.grants_operation("bulk-submit"));
143        assert!(!set.grants_operation("export"));
144        assert!(!set.has_system_wildcard());
145    }
146
147    #[test]
148    fn test_grants_operation_via_wildcard() {
149        let set = ScopeSet::parse("system/*.cruds");
150        assert!(set.has_system_wildcard());
151        // A system wildcard is treated as granting any operation.
152        assert!(set.grants_operation("bulk-submit"));
153    }
154
155    #[test]
156    fn test_grants_operation_array_claim() {
157        let set = ScopeSet::parse_array(&[
158            "system/bulk-submit".to_string(),
159            "system/Patient.rs".to_string(),
160        ]);
161        assert!(set.grants_operation("bulk-submit"));
162        assert!(set.is_permitted("Patient", SmartPermissions::READ));
163        assert!(!set.has_system_wildcard());
164    }
165
166    #[test]
167    fn test_raw_retained() {
168        let set = ScopeSet::parse("system/Patient.rs system/bulk-submit");
169        assert_eq!(set.raw().len(), 2);
170    }
171}