Skip to main content

helios_auth/scope/
smart_v2.rs

1use super::permissions::SmartPermissions;
2use std::fmt;
3
4/// The access context of a SMART scope.
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub enum ScopeContext {
7    /// System-level access (backend services, no user context).
8    System,
9    /// User-level access (scoped to the authenticated user).
10    User,
11    /// Patient-level access (scoped to a specific patient).
12    Patient,
13}
14
15impl fmt::Display for ScopeContext {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            ScopeContext::System => write!(f, "system"),
19            ScopeContext::User => write!(f, "user"),
20            ScopeContext::Patient => write!(f, "patient"),
21        }
22    }
23}
24
25/// The resource type specifier in a SMART scope.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub enum ResourceTypeSpec {
28    /// A specific FHIR resource type (e.g., "Patient").
29    Specific(String),
30    /// Wildcard — applies to all resource types.
31    Wildcard,
32}
33
34impl fmt::Display for ResourceTypeSpec {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            ResourceTypeSpec::Specific(t) => write!(f, "{}", t),
38            ResourceTypeSpec::Wildcard => write!(f, "*"),
39        }
40    }
41}
42
43/// A single parsed SMART v2 scope.
44///
45/// Follows the format: `context/resourceType.permissions`
46///
47/// Examples:
48/// - `system/Patient.rs` — system-level read+search on Patient
49/// - `system/*.cruds` — system-level full CRUD+search on all types
50/// - `user/Observation.r` — user-level read on Observation
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct SmartScope {
53    /// The access context.
54    pub context: ScopeContext,
55    /// The resource type or wildcard.
56    pub resource_type: ResourceTypeSpec,
57    /// The granted permissions.
58    pub permissions: SmartPermissions,
59}
60
61impl SmartScope {
62    /// Parse a single SMART v2 scope string.
63    ///
64    /// Expected format: `context/resourceType.permissions`
65    /// Returns `None` if the string is malformed.
66    pub fn parse(scope_str: &str) -> Option<Self> {
67        // Split into context and rest: "system/Patient.rs"
68        let (context_str, rest) = scope_str.split_once('/')?;
69
70        let context = match context_str {
71            "system" => ScopeContext::System,
72            "user" => ScopeContext::User,
73            "patient" => ScopeContext::Patient,
74            _ => return None,
75        };
76
77        // Split rest into resource type and permissions: "Patient.rs"
78        let (resource_str, perm_str) = rest.split_once('.')?;
79
80        if resource_str.is_empty() || perm_str.is_empty() {
81            return None;
82        }
83
84        let resource_type = if resource_str == "*" {
85            ResourceTypeSpec::Wildcard
86        } else {
87            ResourceTypeSpec::Specific(resource_str.to_string())
88        };
89
90        let permissions = SmartPermissions::from_permission_str(perm_str)?;
91
92        Some(SmartScope {
93            context,
94            resource_type,
95            permissions,
96        })
97    }
98
99    /// Check if this scope grants the given permission on the given resource type.
100    pub fn permits(&self, resource_type: &str, permission: SmartPermissions) -> bool {
101        let type_matches = match &self.resource_type {
102            ResourceTypeSpec::Wildcard => true,
103            ResourceTypeSpec::Specific(t) => t == resource_type,
104        };
105
106        type_matches && self.permissions.contains(permission)
107    }
108}
109
110impl fmt::Display for SmartScope {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "{}/{}.{}",
115            self.context, self.resource_type, self.permissions
116        )
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_parse_system_patient_rs() {
126        let scope = SmartScope::parse("system/Patient.rs").unwrap();
127        assert_eq!(scope.context, ScopeContext::System);
128        assert_eq!(
129            scope.resource_type,
130            ResourceTypeSpec::Specific("Patient".to_string())
131        );
132        assert_eq!(
133            scope.permissions,
134            SmartPermissions::READ | SmartPermissions::SEARCH
135        );
136    }
137
138    #[test]
139    fn test_parse_wildcard_cruds() {
140        let scope = SmartScope::parse("system/*.cruds").unwrap();
141        assert_eq!(scope.context, ScopeContext::System);
142        assert_eq!(scope.resource_type, ResourceTypeSpec::Wildcard);
143        assert!(scope.permissions.contains(SmartPermissions::CREATE));
144        assert!(scope.permissions.contains(SmartPermissions::READ));
145        assert!(scope.permissions.contains(SmartPermissions::UPDATE));
146        assert!(scope.permissions.contains(SmartPermissions::DELETE));
147        assert!(scope.permissions.contains(SmartPermissions::SEARCH));
148    }
149
150    #[test]
151    fn test_parse_user_context() {
152        let scope = SmartScope::parse("user/Observation.r").unwrap();
153        assert_eq!(scope.context, ScopeContext::User);
154        assert_eq!(
155            scope.resource_type,
156            ResourceTypeSpec::Specific("Observation".to_string())
157        );
158        assert_eq!(scope.permissions, SmartPermissions::READ);
159    }
160
161    #[test]
162    fn test_parse_patient_context() {
163        let scope = SmartScope::parse("patient/MedicationRequest.crud").unwrap();
164        assert_eq!(scope.context, ScopeContext::Patient);
165    }
166
167    #[test]
168    fn test_parse_invalid_scopes() {
169        assert!(SmartScope::parse("").is_none());
170        assert!(SmartScope::parse("system").is_none());
171        assert!(SmartScope::parse("system/").is_none());
172        assert!(SmartScope::parse("system/Patient").is_none());
173        assert!(SmartScope::parse("system/.rs").is_none());
174        assert!(SmartScope::parse("system/Patient.").is_none());
175        assert!(SmartScope::parse("system/Patient.xyz").is_none());
176        assert!(SmartScope::parse("unknown/Patient.rs").is_none());
177        assert!(SmartScope::parse("foo/bar/baz").is_none());
178    }
179
180    #[test]
181    fn test_permits() {
182        let scope = SmartScope::parse("system/Patient.rs").unwrap();
183        assert!(scope.permits("Patient", SmartPermissions::READ));
184        assert!(scope.permits("Patient", SmartPermissions::SEARCH));
185        assert!(!scope.permits("Patient", SmartPermissions::CREATE));
186        assert!(!scope.permits("Observation", SmartPermissions::READ));
187    }
188
189    #[test]
190    fn test_permits_wildcard() {
191        let scope = SmartScope::parse("system/*.rs").unwrap();
192        assert!(scope.permits("Patient", SmartPermissions::READ));
193        assert!(scope.permits("Observation", SmartPermissions::SEARCH));
194        assert!(!scope.permits("Patient", SmartPermissions::DELETE));
195    }
196
197    #[test]
198    fn test_display_roundtrip() {
199        let original = "system/Patient.rs";
200        let scope = SmartScope::parse(original).unwrap();
201        assert_eq!(format!("{}", scope), original);
202    }
203}