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.security.SmartPermissions.SmartV2Permission;
014import jakarta.annotation.Nonnull;
015import jakarta.annotation.Nullable;
016import org.apache.commons.lang3.StringUtils;
017import org.apache.commons.lang3.Validate;
018
019import java.util.Objects;
020import java.util.Optional;
021import java.util.Set;
022
023import static ca.cdr.api.security.ScopeConstants.SUPPORTED_SCOPE_PREFIXES;
024
025/**
026 * Parsed smart scope for access to clinical data.
027 * <a href="http://hl7.org/fhir/smart-app-launch/scopes-and-launch-context.html">...</a>
028 * Only clinical scopes; not used for context, identity, or extension scopes.
029 */
030public final class SmartClinicalScope implements Comparable<SmartClinicalScope> {
031        @Nonnull
032        private final String myPrefix;
033
034        @Nonnull
035        private final String myResourceType;
036
037        @Nonnull
038        private final SmartPermissions myPermissions;
039
040        @Nullable
041        private final String myFilter;
042
043        /**
044         * Inspect the scope, and parse if it is a resource access scope.
045         * e.g. "patient/*.read?code=foo" will parse, but "openid" will not.
046         * @param theScope the requested scope
047         * @return a parsed SmartClinicalScope if the scope affected resource access.
048         */
049        public static Optional<SmartClinicalScope> fromScopeString(String theScope) {
050                // "patient/Observation.read?code=foo"
051                theScope = StringUtils.trim(theScope);
052                if (StringUtils.isEmpty(theScope)) {
053                        return Optional.empty();
054                }
055                int slashIdx = theScope.indexOf('/');
056                if (slashIdx == -1) {
057                        Logs.getSecurityTroubleshootingLog().trace("Not a clinical scope - no /: {}", theScope);
058                        return Optional.empty();
059                }
060                int dotIdx = theScope.indexOf('.', slashIdx);
061                if (dotIdx == -1) {
062                        Logs.getSecurityTroubleshootingLog().trace("Not a clinical scope - no .: {}", theScope);
063                        return Optional.empty();
064                }
065                int questIdx = theScope.indexOf('?', dotIdx);
066
067                String compartment = theScope.substring(0, slashIdx);
068                String resourceType = theScope.substring(slashIdx + 1, dotIdx);
069                String perms;
070                String filter = null;
071                if (questIdx == -1) {
072                        perms = theScope.substring(dotIdx + 1);
073                } else {
074                        perms = theScope.substring(dotIdx + 1, questIdx);
075                        filter = theScope.substring(questIdx + 1);
076                        if (filter.isBlank()) {
077                                filter = null;
078                        }
079                }
080
081                if (isValidPrefix(compartment)
082                                && isValidResourceType(resourceType)
083                                && SmartPermissions.isValidPermission(perms)) {
084                        return Optional.of(
085                                        new SmartClinicalScope(compartment, resourceType, SmartPermissions.parse(perms), filter));
086                } else {
087                        Logs.getSecurityTroubleshootingLog().debug("Unrecognized scope: {}", theScope);
088                        return Optional.empty();
089                }
090        }
091
092        SmartClinicalScope(
093                        @Nonnull String thePrefix,
094                        @Nonnull String theResourceType,
095                        @Nonnull SmartPermissions thePerms,
096                        @Nullable String theFilter) {
097
098                Validate.notEmpty(thePrefix);
099                Validate.notNull(thePerms);
100                Validate.notEmpty(theResourceType);
101
102                myPrefix = thePrefix;
103                myResourceType = theResourceType;
104                myPermissions = thePerms;
105                myFilter = theFilter;
106        }
107
108        /**
109         * Get the prefix.  E.g. "patient" from patient/Observation.read
110         */
111        @Nonnull
112        public String getPrefix() {
113                return myPrefix;
114        }
115
116        /**
117         * Get the resource scope.  E.g. "Observation" from patient/Observation.read
118         * Can be "*"
119         */
120        @Nonnull
121        public String getResourceType() {
122                return myResourceType;
123        }
124
125        /**
126         * Is the resource scope "*".  E.g. patient/*.read?code=foo
127         */
128        public boolean isStarType() {
129                return myResourceType.equals("*");
130        }
131
132        @Nonnull
133        public Set<SmartV2Permission> getV2Permissions() {
134                return myPermissions.getV2Permissions();
135        }
136
137        /**
138         * Does the scope have the given V2 permission, or the equivalent from v1.
139         */
140        public boolean hasV2Permission(SmartV2Permission read) {
141                return myPermissions.hasV2Permission(read);
142        }
143
144        /**
145         * Get the (optional) filter from the scope. E.g. "code=foo" from patient/*.read?code=foo
146         */
147        @Nullable
148        public String getFilter() {
149                return myFilter;
150        }
151
152        public boolean hasFilter() {
153                return StringUtils.isNotEmpty(myFilter);
154        }
155
156        /** one of patient, user, or system */
157        private static boolean isValidPrefix(String thePrefix) {
158                return SUPPORTED_SCOPE_PREFIXES.contains(thePrefix + '/');
159        }
160
161        /**
162         * alpha-numeric or *
163         */
164        private static boolean isValidResourceType(String theResourceType) {
165                return "*".equals(theResourceType) || StringUtils.isAlphanumeric(theResourceType);
166        }
167
168        /**
169         * Is this scope implied by theOtherScope.
170         * We need to know if requested scopes are permitted by the registered allowed scopes.
171         * This isn't just simple string-set membership since the allowed scopes might be patient/*.read
172         * while the requested scope could be patient/Observation.read, or patient/Observation.r
173         */
174        public boolean isImpliedBy(SmartClinicalScope theOtherScope) {
175                return Objects.equals(theOtherScope.getPrefix(), getPrefix())
176                                && (theOtherScope.isStarType() || Objects.equals(theOtherScope.getResourceType(), getResourceType()))
177                                && (theOtherScope.getV2Permissions().containsAll(this.getV2Permissions()))
178                                && (!theOtherScope.hasFilter() || Objects.equals(theOtherScope.myFilter, myFilter));
179        }
180
181        @Override
182        public String toString() {
183                return "SmartClinicalScope(" + getScopeString() + ")";
184        }
185
186        @Nonnull
187        private String getScopeString() {
188                return myPrefix + "/" + myResourceType + "." + myPermissions.getPermissionString()
189                                + (StringUtils.isNotEmpty(myFilter) ? ("?" + myFilter) : "");
190        }
191
192        @Override
193        public boolean equals(Object theOther) {
194                if (this == theOther) {
195                        return true;
196                }
197                if (theOther == null || getClass() != theOther.getClass()) {
198                        return false;
199                }
200                SmartClinicalScope that = (SmartClinicalScope) theOther;
201                return myPrefix.equals(that.myPrefix)
202                                && myResourceType.equals(that.myResourceType)
203                                && myPermissions.equals(that.myPermissions)
204                                && Objects.equals(myFilter, that.myFilter);
205        }
206
207        @Override
208        public int hashCode() {
209                return Objects.hash(myPrefix, myResourceType, myPermissions, myFilter);
210        }
211
212        public boolean hasSearch() {
213                return hasV2Permission(SmartV2Permission.SEARCH);
214        }
215
216        @Override
217        public int compareTo(@Nonnull SmartClinicalScope o) {
218                return toString().compareTo(o.toString());
219        }
220}