001/*-
002 * #%L
003 * Smile CDR - CDR
004 * %%
005 * Copyright (C) 2016 - 2025 Smile CDR, Inc.
006 * %%
007 * All rights reserved.
008 * #L%
009 */
010package ca.cdr.api.security;
011
012import ca.cdr.api.log.Logs;
013import ca.cdr.api.model.enm.PermissionEnum;
014import ca.cdr.api.model.json.GrantedAuthorityJson;
015import ca.cdr.api.security.permission.CompartmentWithOptionalFilter;
016import ca.cdr.api.security.permission.InstanceWithOptionalFilter;
017import ca.cdr.api.security.permission.OptionalFilter;
018import ca.cdr.api.security.permission.PermissionArgumentFormat;
019import ca.cdr.api.security.permission.PermissionArgumentParser;
020import ca.cdr.api.security.permission.PermissionArgumentValue;
021import ca.cdr.api.security.permission.ResourceTypeOptionalFilter;
022import ca.cdr.api.security.permission.TypeInCompartmentWithOptionalFilter;
023import jakarta.annotation.Nonnull;
024import jakarta.annotation.Nullable;
025import org.apache.commons.lang3.StringUtils;
026import org.apache.commons.lang3.Validate;
027import org.hl7.fhir.instance.model.api.IIdType;
028
029import java.util.Objects;
030import java.util.Optional;
031
032import static ca.cdr.api.security.ScopeConstants.SUPPORTED_RESOURCE_OPERATIONS;
033import static ca.cdr.api.security.ScopeConstants.SUPPORTED_SCOPE_PREFIXES;
034
035/**
036 * Parsed smart scope for access to clinical data.
037 * <a href="http://hl7.org/fhir/smart-app-launch/scopes-and-launch-context.html">...</a>
038 * Only clinical scopes; not used for context, identity, or extension scopes.
039 */
040public final class SmartClinicalScope {
041        @Nonnull
042        private final String myPrefix;
043
044        @Nonnull
045        private final String myResourceType;
046
047        @Nonnull
048        private final String myPermissions;
049
050        @Nullable
051        private final String myFilter;
052
053        /**
054         * Inspect the scope, and parse if it is a resource access scope.
055         * e.g. "patient/*.read?code=foo" will parse, but "openid" will not.
056         * @param theScope the requested scope
057         * @return a parsed SmartClinicalScope if the scope affected resource access.
058         */
059        public static Optional<SmartClinicalScope> fromScopeString(String theScope) {
060                // "patient/Observation.read?code=foo"
061                int slashIdx = theScope.indexOf('/');
062                if (slashIdx == -1) {
063                        Logs.getSecurityTroubleshootingLog().trace("Not a clinical scope - no /: {}", theScope);
064                        return Optional.empty();
065                }
066                int dotIdx = theScope.indexOf('.', slashIdx);
067                if (dotIdx == -1) {
068                        Logs.getSecurityTroubleshootingLog().trace("Not a clinical scope - no .: {}", theScope);
069                        return Optional.empty();
070                }
071                int questIdx = theScope.indexOf('?', dotIdx);
072
073                String compartment = theScope.substring(0, slashIdx);
074                String resourceType = theScope.substring(slashIdx + 1, dotIdx);
075                String perms;
076                String filter = null;
077                if (questIdx == -1) {
078                        perms = theScope.substring(dotIdx + 1);
079                } else {
080                        perms = theScope.substring(dotIdx + 1, questIdx);
081                        filter = theScope.substring(questIdx + 1);
082                }
083
084                boolean validPermission = isValidPermission(perms);
085                if (isValidPrefix(compartment) && isValidResourceType(resourceType) && validPermission) {
086                        return Optional.of(new SmartClinicalScope(compartment, resourceType, perms, filter));
087                }
088                // add warning as it is not supported for now
089                if (!validPermission) {
090                        Logs.getSecurityTroubleshootingLog().warn("Smile only supports *, read or write but received: {}", perms);
091                }
092                Logs.getSecurityTroubleshootingLog().debug("Unrecognized scope: {}", theScope);
093                return Optional.empty();
094        }
095
096        SmartClinicalScope(
097                        @Nonnull String thePrefix,
098                        @Nonnull String theResourceType,
099                        @Nonnull String thePerms,
100                        @Nullable String theFilter) {
101                Validate.notEmpty(thePrefix);
102                Validate.notEmpty(theResourceType);
103                Validate.notEmpty(thePerms);
104                myPrefix = thePrefix;
105                myResourceType = theResourceType;
106                myPermissions = thePerms;
107                myFilter = theFilter;
108        }
109
110        /**
111         * Get the prefix.  E.g. "patient" from patient/Observation.read
112         */
113        @Nonnull
114        public String getPrefix() {
115                return myPrefix;
116        }
117
118        /**
119         * Get the resource scope.  E.g. "Observation" from patient/Observation.read
120         * Can be "*"
121         */
122        @Nonnull
123        public String getResourceType() {
124                return myResourceType;
125        }
126
127        /**
128         * Is the resource scope "*".  E.g. patient/*.read?code=foo
129         */
130        public boolean isStarType() {
131                return myResourceType.equals("*");
132        }
133
134        /**
135         * Get the permissions.  E.g. "read" from patient/Observation.read
136         * Can be "*"
137         */
138        @Nonnull
139        public String getPermissions() {
140                return myPermissions;
141        }
142
143        /**
144         * Do the permissions allow read.
145         * @return true for "read" or "*"
146         */
147        public boolean isRead() {
148                return myPermissions.equals("read") || myPermissions.equals("*");
149        }
150
151        /**
152         * Do the permissions allow write.
153         * @return true for "write" or "*"
154         */
155        public boolean isWrite() {
156                return myPermissions.equals("write") || myPermissions.equals("*");
157        }
158
159        /**
160         * Get the (optional) filter from the scope. E.g. "code=foo" from patient/*.read?code=foo
161         */
162        @Nullable
163        public String getFilter() {
164                return myFilter;
165        }
166
167        public boolean hasFilter() {
168                return myFilter != null;
169        }
170
171        /** one of patient, user, or system */
172        private static boolean isValidPrefix(String thePrefix) {
173                return SUPPORTED_SCOPE_PREFIXES.contains(thePrefix + '/');
174        }
175
176        /**
177         * alpha-numeric or *
178         */
179        private static boolean isValidResourceType(String theResourceType) {
180                return "*".equals(theResourceType) || StringUtils.isAlphanumeric(theResourceType);
181        }
182
183        /**
184         * Are permissions read, write, or *?
185         * We don't support .cruds yet
186         */
187        private static boolean isValidPermission(String thePermission) {
188                return SUPPORTED_RESOURCE_OPERATIONS.contains(thePermission) || "*".equals(thePermission);
189        }
190
191        /**
192         * Compute the intersection of the authority and this scope.
193         * E.g. patient/Observation.read should narrow FHIR_READ_ALL to FHIR_READ_ALL_OF_TYPE/Observation.
194         * patient/Observation.read will narrow FHIR_READ_ALL_OF_TYPE/Patient to empty.
195         *
196         * @param theGrantedAuthorityJson the users authority
197         * @return the authority narrowed by this scope if applicable
198         */
199        @Nonnull
200        public Optional<GrantedAuthorityJson> computeNarrowedAuthority(GrantedAuthorityJson theGrantedAuthorityJson) {
201                Optional<GrantedAuthorityJson> result = Optional.empty();
202                PermissionArgumentParser parser = PermissionArgumentParser.withoutFhirContext();
203                Optional<PermissionArgumentValue> maybeArg = parser.parse(theGrantedAuthorityJson);
204                PermissionArgumentValue argument;
205                if (maybeArg.isEmpty()) {
206                        // invalid argument
207                        Logs.getSecurityTroubleshootingLog()
208                                        .info("Ignoring GA because argument does not match format. {}", theGrantedAuthorityJson);
209                        return Optional.empty();
210                } else {
211                        argument = maybeArg.get();
212                }
213                switch (theGrantedAuthorityJson.getPermission()) {
214                        case FHIR_ALL_READ:
215                        case FHIR_READ_ALL_IN_COMPARTMENT:
216                        case FHIR_READ_TYPE_IN_COMPARTMENT:
217                        case FHIR_READ_ALL_OF_TYPE:
218                                if (isRead()) {
219                                        String narrowedType = narrowType(argument);
220                                        Optional<IIdType> compartment = getCompartmentOwner(argument);
221                                        if (isCompartmentConflict(compartment)) {
222                                                break;
223                                        }
224                                        String newFilter = intersectFilter(argument);
225                                        if (narrowedType == null) {
226                                                // no match
227                                                break;
228                                        } else if (narrowedType.equals("*")) {
229                                                // all types
230                                                if (compartment.isPresent()) {
231                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
232                                                                        PermissionEnum.FHIR_READ_ALL_IN_COMPARTMENT,
233                                                                        CompartmentWithOptionalFilter.build(compartment.get(), newFilter)));
234                                                } else {
235                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
236                                                                        PermissionEnum.FHIR_ALL_READ, OptionalFilter.withFilter(newFilter)));
237                                                }
238                                        } else {
239                                                // we have a type
240                                                if (compartment.isPresent()) {
241                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
242                                                                        PermissionEnum.FHIR_READ_TYPE_IN_COMPARTMENT,
243                                                                        TypeInCompartmentWithOptionalFilter.build(
244                                                                                        narrowedType, compartment.get(), newFilter)));
245                                                } else {
246                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
247                                                                        PermissionEnum.FHIR_READ_ALL_OF_TYPE,
248                                                                        ResourceTypeOptionalFilter.build(narrowedType, newFilter)));
249                                                }
250                                        }
251                                }
252                                break;
253                        case FHIR_READ_INSTANCE:
254                                if (isRead()) {
255                                        String narrowedType = narrowType(argument);
256                                        if (narrowedType == null) {
257                                                break;
258                                        }
259                                        InstanceWithOptionalFilter idArg = (InstanceWithOptionalFilter) argument;
260                                        String newFilter = intersectFilter(argument);
261                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
262                                                        PermissionEnum.FHIR_READ_INSTANCE,
263                                                        InstanceWithOptionalFilter.build(idArg.getInstanceId(), newFilter)));
264                                }
265                                break;
266                        case FHIR_ALL_WRITE:
267                        case FHIR_WRITE_ALL_IN_COMPARTMENT:
268                        case FHIR_WRITE_TYPE_IN_COMPARTMENT:
269                        case FHIR_WRITE_ALL_OF_TYPE:
270                                if (isWrite()) {
271                                        String narrowedType = narrowType(argument);
272                                        Optional<IIdType> compartment = getCompartmentOwner(argument);
273                                        if (isCompartmentConflict(compartment)) {
274                                                break;
275                                        }
276                                        String newFilter = intersectFilter(argument);
277                                        if (narrowedType == null) {
278                                                // no match
279                                                break;
280                                        } else if (narrowedType.equals("*")) {
281                                                // all types
282                                                if (compartment.isPresent()) {
283                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
284                                                                        PermissionEnum.FHIR_WRITE_ALL_IN_COMPARTMENT,
285                                                                        CompartmentWithOptionalFilter.build(compartment.get(), newFilter)));
286                                                } else {
287                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
288                                                                        PermissionEnum.FHIR_ALL_WRITE, OptionalFilter.withFilter(newFilter)));
289                                                }
290                                        } else {
291                                                // we have a type
292                                                if (compartment.isPresent()) {
293                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
294                                                                        PermissionEnum.FHIR_WRITE_TYPE_IN_COMPARTMENT,
295                                                                        TypeInCompartmentWithOptionalFilter.build(
296                                                                                        narrowedType, compartment.get(), newFilter)));
297                                                } else {
298                                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
299                                                                        PermissionEnum.FHIR_WRITE_ALL_OF_TYPE,
300                                                                        ResourceTypeOptionalFilter.build(narrowedType, newFilter)));
301                                                }
302                                        }
303                                }
304                                break;
305                        case FHIR_WRITE_INSTANCE:
306                                if (isWrite()) {
307                                        String narrowedType = narrowType(argument);
308                                        if (narrowedType == null) {
309                                                break;
310                                        }
311                                        InstanceWithOptionalFilter idArg = (InstanceWithOptionalFilter) argument;
312                                        String newFilter = intersectFilter(argument);
313                                        result = Optional.of(PermissionArgumentFormat.buildAuthority(
314                                                        PermissionEnum.FHIR_WRITE_INSTANCE,
315                                                        InstanceWithOptionalFilter.build(idArg.getInstanceId(), newFilter)));
316                                }
317                                break;
318
319                        default:
320                                // do nothing.
321                                break;
322                }
323                Logs.getSecurityTroubleshootingLog().debug("Narrowing {} by {} to {}", theGrantedAuthorityJson, this, result);
324
325                return result;
326        }
327
328        /**
329         * Is this scope compatible with the compartment? patient/*.read is only compatible with the Patient compartment,
330         * while the system and user prefixes are compatible with Patient, Device, Practitioner, etc.
331         *
332         * @return false if the compartment matches our prefix or there is no compartment
333         */
334        boolean isCompartmentConflict(Optional<IIdType> theCompartment) {
335                if (theCompartment.isPresent() && (myPrefix.equals("patient"))) {
336                        return !"Patient".equals(theCompartment.get().getResourceType());
337                }
338                return false;
339        }
340
341        private Optional<IIdType> getCompartmentOwner(PermissionArgumentFormat.IPermissionComponent theArgument) {
342                if (theArgument instanceof PermissionArgumentFormat.ICompartmentRestriction iCompartmentRestriction) {
343                        PermissionArgumentFormat.ICompartmentRestriction argument = iCompartmentRestriction;
344                        return Optional.of(argument.getOwner());
345                }
346                return Optional.empty();
347        }
348
349        /**
350         * Combine the (optional) argument filter and this scope filter if present.
351         * @param theArgument the parsed permission argument to inspect for a filter
352         * @return the intersection of the two filters or null if none
353         */
354        String intersectFilter(PermissionArgumentFormat.IPermissionComponent theArgument) {
355                if (theArgument instanceof PermissionArgumentFormat.IOptionalFilterRestriction iOptionalFilterRestriction) {
356                        PermissionArgumentFormat.IOptionalFilterRestriction argument = iOptionalFilterRestriction;
357                        if (argument.hasFilter()) {
358                                String argFilter = argument.getFilter().orElseThrow();
359                                if (myFilter != null) {
360                                        return argFilter + "&" + myFilter;
361                                } else {
362                                        return argFilter;
363                                }
364                        }
365                }
366                return myFilter;
367        }
368
369        /**
370         * Compare a permission argument against this scope type restriction (e.g. * or Observation)
371         * and compute the intersection.
372         *
373         * @param theArgument The PermissionEnum argument
374         * @return One of "*" (for all types), null (for empty), or a type name (e.g. "Observation")
375         */
376        String narrowType(PermissionArgumentFormat.IPermissionComponent theArgument) {
377                if (theArgument instanceof PermissionArgumentFormat.IResourceTypeRestriction itypeRestriction) {
378                        PermissionArgumentFormat.IResourceTypeRestriction typeRestriction = itypeRestriction;
379                        if (isStarType()) {
380                                return typeRestriction.getResourceType();
381                        } else if (getResourceType().equals(typeRestriction.getResourceType())) {
382                                return getResourceType();
383                        } else {
384                                return null;
385                        }
386                } else {
387                        return myResourceType;
388                }
389        }
390
391        public boolean isImpliedBy(SmartClinicalScope theOtherScope) {
392                return getPrefix().equals(theOtherScope.getPrefix())
393                                && (theOtherScope.isStarType()
394                                                || theOtherScope.getResourceType().equals(this.getResourceType()))
395                                && theOtherScope.getPermissions().equals(this.getPermissions())
396                                && (!theOtherScope.hasFilter() || Objects.equals(theOtherScope.myFilter, myFilter));
397        }
398
399        @Override
400        public String toString() {
401                return "SmartClinicalScope(" + myPrefix
402                                + "/" + myResourceType + "." + myPermissions
403                                + (StringUtils.isNotEmpty(myFilter) ? ("?" + myFilter) : "") + ")";
404        }
405
406        @Override
407        public boolean equals(Object theOther) {
408                if (this == theOther) {
409                        return true;
410                }
411                if (theOther == null || getClass() != theOther.getClass()) {
412                        return false;
413                }
414                SmartClinicalScope that = (SmartClinicalScope) theOther;
415                return myPrefix.equals(that.myPrefix)
416                                && myResourceType.equals(that.myResourceType)
417                                && myPermissions.equals(that.myPermissions)
418                                && Objects.equals(myFilter, that.myFilter);
419        }
420
421        @Override
422        public int hashCode() {
423                return Objects.hash(myPrefix, myResourceType, myPermissions, myFilter);
424        }
425
426        public boolean hasSearch() {
427                return isRead() || myPermissions.contains("s");
428        }
429}