haste_jwt/
scopes.rs

1use haste_fhir_model::r4::generated::{resources::ResourceType, terminology::IssueType};
2use haste_fhir_operation_error::OperationOutcomeError;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, PartialEq, Eq, Clone)]
6pub enum OIDCScope {
7    OpenId,
8    Profile,
9    Email,
10    OfflineAccess,
11    OnlineAccess,
12}
13
14impl From<OIDCScope> for String {
15    fn from(value: OIDCScope) -> Self {
16        match value {
17            OIDCScope::OpenId => "openid".to_string(),
18            OIDCScope::Profile => "profile".to_string(),
19            OIDCScope::Email => "email".to_string(),
20            OIDCScope::OfflineAccess => "offline_access".to_string(),
21            OIDCScope::OnlineAccess => "online_access".to_string(),
22        }
23    }
24}
25
26impl TryFrom<&str> for OIDCScope {
27    type Error = OperationOutcomeError;
28
29    fn try_from(value: &str) -> Result<Self, Self::Error> {
30        match value {
31            "openid" => Ok(Self::OpenId),
32            "profile" => Ok(Self::Profile),
33            "email" => Ok(Self::Email),
34            "offline_access" => Ok(Self::OfflineAccess),
35            "online_access" => Ok(Self::OnlineAccess),
36            _ => Err(OperationOutcomeError::error(
37                IssueType::NotSupported(None),
38                format!("OIDC Scope '{}' not supported.", value),
39            )),
40        }
41    }
42}
43
44#[derive(Debug, PartialEq, Eq, Clone)]
45pub struct LaunchSystemScope;
46
47impl From<LaunchSystemScope> for String {
48    fn from(_: LaunchSystemScope) -> Self {
49        "launch".to_string()
50    }
51}
52
53#[derive(Debug, PartialEq, Eq, Clone)]
54pub enum LaunchType {
55    Encounter,
56    Patient,
57}
58
59impl TryFrom<&str> for LaunchType {
60    type Error = OperationOutcomeError;
61
62    fn try_from(value: &str) -> Result<Self, Self::Error> {
63        match value {
64            "encounter" => Ok(LaunchType::Encounter),
65            "patient" => Ok(LaunchType::Patient),
66            _ => Err(OperationOutcomeError::error(
67                IssueType::NotSupported(None),
68                format!("Launch type '{}' not supported.", value),
69            )),
70        }
71    }
72}
73
74#[derive(Debug, PartialEq, Eq, Clone)]
75pub struct LaunchTypeScope {
76    pub launch_type: LaunchType,
77}
78
79impl From<LaunchTypeScope> for String {
80    fn from(value: LaunchTypeScope) -> Self {
81        match value.launch_type {
82            LaunchType::Encounter => "launch/encounter".to_string(),
83            LaunchType::Patient => "launch/patient".to_string(),
84        }
85    }
86}
87
88#[derive(Debug, PartialEq, Eq, Clone)]
89pub enum SmartResourceScopeUser {
90    User,
91    System,
92    Patient,
93}
94
95impl TryFrom<&str> for SmartResourceScopeUser {
96    type Error = OperationOutcomeError;
97
98    fn try_from(value: &str) -> Result<Self, Self::Error> {
99        match value {
100            "user" => Ok(SmartResourceScopeUser::User),
101            "system" => Ok(SmartResourceScopeUser::System),
102            "patient" => Ok(SmartResourceScopeUser::Patient),
103            _ => Err(OperationOutcomeError::error(
104                IssueType::NotSupported(None),
105                format!("Smart resource scope level '{}' not supported.", value),
106            )),
107        }
108    }
109}
110
111#[derive(Debug, PartialEq, Eq, Clone)]
112pub enum SmartResourceScopeLevel {
113    ResourceType(ResourceType),
114    AllResources,
115}
116
117impl TryFrom<&str> for SmartResourceScopeLevel {
118    type Error = OperationOutcomeError;
119
120    fn try_from(value: &str) -> Result<Self, Self::Error> {
121        match value {
122            "*" => Ok(SmartResourceScopeLevel::AllResources),
123            resource_type => {
124                let resource_type = ResourceType::try_from(value).map_err(|_e| {
125                    OperationOutcomeError::error(
126                        IssueType::NotSupported(None),
127                        format!(
128                            "Smart resource scope resource type '{}' not supported.",
129                            resource_type,
130                        ),
131                    )
132                })?;
133                Ok(SmartResourceScopeLevel::ResourceType(resource_type))
134            }
135        }
136    }
137}
138
139#[derive(Debug, PartialEq, Eq, Clone)]
140pub enum SmartResourceScopePermission {
141    Create,
142    Read,
143    Update,
144    Delete,
145    Search,
146}
147
148#[derive(Debug, PartialEq, Eq, Clone)]
149pub struct SmartResourceScopePermissions(Vec<SmartResourceScopePermission>);
150
151impl SmartResourceScopePermissions {
152    pub fn new(permissions: Vec<SmartResourceScopePermission>) -> Self {
153        Self(permissions)
154    }
155
156    pub fn has_permission(&self, permission: &SmartResourceScopePermission) -> bool {
157        self.0.contains(permission)
158    }
159
160    pub fn add_permission(&mut self, permission: SmartResourceScopePermission) {
161        if !self.has_permission(&permission) {
162            self.0.push(permission);
163        }
164    }
165}
166
167static SMART_RESOURCE_SCOPE_PERMISSION_ORDER: &[char] = &['c', 'r', 'u', 'd', 's'];
168
169impl TryFrom<&str> for SmartResourceScopePermissions {
170    type Error = OperationOutcomeError;
171
172    fn try_from(value: &str) -> Result<Self, Self::Error> {
173        match value {
174            "*" => Ok(SmartResourceScopePermissions::new(vec![
175                SmartResourceScopePermission::Create,
176                SmartResourceScopePermission::Read,
177                SmartResourceScopePermission::Update,
178                SmartResourceScopePermission::Delete,
179                SmartResourceScopePermission::Search,
180            ])),
181            "write" => Ok(SmartResourceScopePermissions::new(vec![
182                SmartResourceScopePermission::Create,
183                SmartResourceScopePermission::Update,
184                SmartResourceScopePermission::Delete,
185            ])),
186            "read" => Ok(SmartResourceScopePermissions::new(vec![
187                SmartResourceScopePermission::Read,
188                SmartResourceScopePermission::Search,
189            ])),
190            methods => {
191                let mut methods_obj = SmartResourceScopePermissions::new(vec![]);
192
193                // Scope requests with undefined or out of order interactions MAY be ignored, replaced with server default scopes, or rejected.
194                // per [https://build.fhir.org/ig/HL7/smart-app-launch/scopes-and-launch-context.html#scopes-for-requesting-fhir-resources].
195                let mut current_index: i8 = -1;
196                for method in methods.chars() {
197                    let found_index = SMART_RESOURCE_SCOPE_PERMISSION_ORDER
198                        .iter()
199                        .position(|o| *o == method)
200                        .map(|p| p as i8);
201
202                    if found_index <= Some(current_index) || found_index.is_none() {
203                        return Err(OperationOutcomeError::error(
204                            IssueType::NotSupported(None),
205                            format!(
206                                "Invalid scope access type methods: '{}' not supported or in wrong place must be in 'cruds' order.",
207                                method
208                            ),
209                        ));
210                    }
211
212                    current_index = found_index.unwrap_or(0);
213
214                    match method {
215                        /*
216                         * Type level create
217                         */
218                        'c' => {
219                            methods_obj.add_permission(SmartResourceScopePermission::Create);
220                        }
221                        /*
222                         * Instance level read
223                         * Instance level vread
224                         * Instance level history
225                         */
226                        'r' => {
227                            methods_obj.add_permission(SmartResourceScopePermission::Read);
228                        }
229                        /*
230                         * Instance level update Note that some servers allow for an update operation to create a new instance,
231                         * and this is allowed by the update scope
232                         * Instance level patch
233                         */
234                        'u' => {
235                            methods_obj.add_permission(SmartResourceScopePermission::Update);
236                        }
237                        /*
238                         * Instance level delete
239                         */
240                        'd' => {
241                            methods_obj.add_permission(SmartResourceScopePermission::Delete);
242                        }
243                        /*
244                         * Type level search
245                         * Type level history
246                         * System level search
247                         * System level history
248                         */
249                        's' => {
250                            methods_obj.add_permission(SmartResourceScopePermission::Search);
251                        }
252                        _ => {}
253                    }
254                }
255
256                Ok(methods_obj)
257            }
258        }
259    }
260}
261#[derive(Debug, PartialEq, Eq, Clone)]
262pub struct SMARTResourceScope {
263    pub user: SmartResourceScopeUser,
264    pub level: SmartResourceScopeLevel,
265    pub permissions: SmartResourceScopePermissions,
266}
267
268impl From<SMARTResourceScope> for String {
269    fn from(value: SMARTResourceScope) -> Self {
270        let user_str = match value.user {
271            SmartResourceScopeUser::User => "user",
272            SmartResourceScopeUser::System => "system",
273            SmartResourceScopeUser::Patient => "patient",
274        };
275
276        let level_str = match value.level {
277            SmartResourceScopeLevel::AllResources => "*".to_string(),
278            SmartResourceScopeLevel::ResourceType(resource_type) => {
279                resource_type.as_ref().to_string()
280            }
281        };
282
283        let mut permissions_str = String::new();
284        if value
285            .permissions
286            .has_permission(&SmartResourceScopePermission::Create)
287        {
288            permissions_str.push('c');
289        }
290        if value
291            .permissions
292            .has_permission(&SmartResourceScopePermission::Read)
293        {
294            permissions_str.push('r');
295        }
296        if value
297            .permissions
298            .has_permission(&SmartResourceScopePermission::Update)
299        {
300            permissions_str.push('u');
301        }
302        if value
303            .permissions
304            .has_permission(&SmartResourceScopePermission::Delete)
305        {
306            permissions_str.push('d');
307        }
308        if value
309            .permissions
310            .has_permission(&SmartResourceScopePermission::Search)
311        {
312            permissions_str.push('s');
313        }
314
315        format!("{}/{}.{}", user_str, level_str, permissions_str)
316    }
317}
318
319#[derive(Debug, PartialEq, Eq, Clone)]
320pub enum SmartScope {
321    LaunchSystem(LaunchSystemScope),
322    LaunchType(LaunchTypeScope),
323    Resource(SMARTResourceScope),
324    FHIRUser,
325}
326
327impl From<SmartScope> for String {
328    fn from(value: SmartScope) -> Self {
329        match value {
330            SmartScope::FHIRUser => "fhirUser".to_string(),
331            SmartScope::LaunchSystem(launch_system) => String::from(launch_system),
332            SmartScope::LaunchType(launch_type) => String::from(launch_type),
333            SmartScope::Resource(resource) => String::from(resource),
334        }
335    }
336}
337
338impl TryFrom<&str> for SmartScope {
339    type Error = OperationOutcomeError;
340    fn try_from(value: &str) -> Result<Self, Self::Error> {
341        match value {
342            "fhirUser" => Ok(SmartScope::FHIRUser),
343            "launch" => Ok(SmartScope::LaunchSystem(LaunchSystemScope)),
344            _ if value.starts_with("launch/") => {
345                let chunks: Vec<&str> = value.split('/').collect();
346                if chunks.len() != 2 {
347                    return Err(OperationOutcomeError::error(
348                        IssueType::NotSupported(None),
349                        format!("Invalid launch scope: '{}'.", value),
350                    ));
351                }
352
353                let launch_type = LaunchType::try_from(chunks[1])?;
354
355                Ok(SmartScope::LaunchType(LaunchTypeScope { launch_type }))
356            }
357            _ if value.starts_with("user/")
358                || value.starts_with("system/")
359                || value.starts_with("patient/") =>
360            {
361                let parts: Vec<&str> = value.split('/').collect();
362                if parts.len() != 2 {
363                    return Err(OperationOutcomeError::error(
364                        IssueType::NotSupported(None),
365                        format!("Invalid smart resource scope: '{}'.", value),
366                    ));
367                }
368
369                let user = SmartResourceScopeUser::try_from(parts[0])?;
370                let permissions_parts: Vec<&str> = parts[1].split('.').collect();
371                if permissions_parts.len() != 2 {
372                    return Err(OperationOutcomeError::error(
373                        IssueType::NotSupported(None),
374                        format!("Invalid smart resource scope: '{}'.", value),
375                    ));
376                }
377
378                let level = SmartResourceScopeLevel::try_from(permissions_parts[0])?;
379                let permissions = SmartResourceScopePermissions::try_from(permissions_parts[1])?;
380
381                Ok(SmartScope::Resource(SMARTResourceScope {
382                    user,
383                    level,
384                    permissions,
385                }))
386            }
387            _ => Err(OperationOutcomeError::error(
388                IssueType::NotSupported(None),
389                format!("Smart Scope '{}' not supported.", value),
390            )),
391        }
392    }
393}
394
395#[derive(Debug, PartialEq, Eq, Clone)]
396pub enum Scope {
397    OIDC(OIDCScope),
398    SMART(SmartScope),
399}
400
401impl TryFrom<&str> for Scope {
402    type Error = OperationOutcomeError;
403
404    fn try_from(value: &str) -> Result<Self, Self::Error> {
405        if let Ok(oidc_scope) = OIDCScope::try_from(value) {
406            Ok(Self::OIDC(oidc_scope))
407        } else {
408            Ok(Self::SMART(SmartScope::try_from(value)?))
409        }
410    }
411}
412
413impl From<Scope> for String {
414    fn from(value: Scope) -> Self {
415        match value {
416            Scope::OIDC(oidc_scope) => String::from(oidc_scope),
417            Scope::SMART(smart_scope) => String::from(smart_scope),
418        }
419    }
420}
421
422#[derive(Debug, PartialEq, Eq, Clone)]
423pub struct Scopes(pub Vec<Scope>);
424
425impl Scopes {
426    pub fn contains_scope(&self, scope: &Scope) -> bool {
427        self.0.contains(scope)
428    }
429}
430
431impl Default for Scopes {
432    fn default() -> Self {
433        Scopes(vec![])
434    }
435}
436
437impl TryFrom<&str> for Scopes {
438    type Error = OperationOutcomeError;
439
440    fn try_from(value: &str) -> Result<Self, Self::Error> {
441        let scopes: Result<Vec<Scope>, OperationOutcomeError> = value
442            .split_whitespace()
443            .map(|s| Scope::try_from(s))
444            .collect();
445
446        Ok(Scopes(scopes?))
447    }
448}
449
450// Used by sqlx binding this is not safe.
451impl From<String> for Scopes {
452    fn from(value: String) -> Self {
453        let scopes = Self::try_from(value.as_str()).expect("Invalid scopes string");
454
455        scopes
456    }
457}
458
459impl<'de> Deserialize<'de> for Scopes {
460    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
461    where
462        D: serde::Deserializer<'de>,
463    {
464        let s = String::deserialize(deserializer)?;
465        Scopes::try_from(s.as_str()).map_err(serde::de::Error::custom)
466    }
467}
468
469impl From<Scopes> for String {
470    fn from(value: Scopes) -> Self {
471        value
472            .0
473            .into_iter()
474            .map(|s| String::from(s))
475            .collect::<Vec<_>>()
476            .join(" ")
477    }
478}
479
480impl Serialize for Scopes {
481    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
482    where
483        S: serde::Serializer,
484    {
485        serializer.serialize_str(&String::from(self.clone()))
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use haste_fhir_model::r4::generated::resources::ResourceType;
493
494    #[test]
495    fn test_multiple_correct() {
496        assert_eq!(
497            Scopes::try_from("openid profile email offline_access launch/patient user/*.*")
498                .unwrap(),
499            Scopes(vec![
500                Scope::OIDC(OIDCScope::OpenId),
501                Scope::OIDC(OIDCScope::Profile),
502                Scope::OIDC(OIDCScope::Email),
503                Scope::OIDC(OIDCScope::OfflineAccess),
504                Scope::SMART(SmartScope::LaunchType(LaunchTypeScope {
505                    launch_type: LaunchType::Patient,
506                })),
507                Scope::SMART(SmartScope::Resource(SMARTResourceScope {
508                    user: SmartResourceScopeUser::User,
509                    level: SmartResourceScopeLevel::AllResources,
510                    permissions: SmartResourceScopePermissions::new(vec![
511                        SmartResourceScopePermission::Create,
512                        SmartResourceScopePermission::Read,
513                        SmartResourceScopePermission::Update,
514                        SmartResourceScopePermission::Delete,
515                        SmartResourceScopePermission::Search,
516                    ])
517                })),
518            ]),
519        );
520
521        assert_eq!(
522            Scopes::try_from("launch/encounter   system/Patient.cud").unwrap(),
523            Scopes(vec![
524                Scope::SMART(SmartScope::LaunchType(LaunchTypeScope {
525                    launch_type: LaunchType::Encounter,
526                })),
527                Scope::SMART(SmartScope::Resource(SMARTResourceScope {
528                    user: SmartResourceScopeUser::System,
529                    level: SmartResourceScopeLevel::ResourceType(ResourceType::Patient),
530                    permissions: SmartResourceScopePermissions::new(vec![
531                        SmartResourceScopePermission::Create,
532                        SmartResourceScopePermission::Update,
533                        SmartResourceScopePermission::Delete,
534                    ])
535                })),
536            ]),
537        );
538    }
539
540    #[test]
541    fn invalid_order() {
542        assert_eq!(
543            Scopes::try_from("launch/encounter   system/Patient.duc").is_err(),
544            true
545        );
546    }
547
548    #[test]
549    fn invalid_system() {
550        assert_eq!(
551            Scopes::try_from("launch/encounter   sytem/Patient.cud").is_err(),
552            true
553        );
554    }
555
556    #[test]
557    fn unknown_scope() {
558        assert_eq!(
559            Scopes::try_from("badscope  sytem/Patient.cud").is_err(),
560            true
561        );
562    }
563
564    #[test]
565    fn test_roundtrip() {
566        assert_eq!(
567            String::from(
568                Scopes::try_from("openid profile email offline_access launch/patient user/*.*")
569                    .unwrap()
570            ),
571            "openid profile email offline_access launch/patient user/*.cruds".to_string(),
572        );
573
574        assert_eq!(
575            String::from(Scopes::try_from("launch/encounter system/Patient.cud").unwrap()),
576            "launch/encounter system/Patient.cud".to_string()
577        );
578    }
579}