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