Skip to main content

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