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 public 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 SmartPermissions getPermissions() { 134 return myPermissions; 135 } 136 137 @Nonnull 138 public Set<SmartV2Permission> getV2Permissions() { 139 return myPermissions.getV2Permissions(); 140 } 141 142 /** 143 * Does the scope have the given V2 permission, or the equivalent from v1. 144 */ 145 public boolean hasV2Permission(SmartV2Permission read) { 146 return myPermissions.hasV2Permission(read); 147 } 148 149 /** 150 * Get the (optional) filter from the scope. E.g. "code=foo" from patient/*.read?code=foo 151 */ 152 @Nullable 153 public String getFilter() { 154 return myFilter; 155 } 156 157 public boolean hasFilter() { 158 return StringUtils.isNotEmpty(myFilter); 159 } 160 161 /** one of patient, user, or system */ 162 private static boolean isValidPrefix(String thePrefix) { 163 return SUPPORTED_SCOPE_PREFIXES.contains(thePrefix + '/'); 164 } 165 166 /** 167 * alpha-numeric or * 168 */ 169 private static boolean isValidResourceType(String theResourceType) { 170 return "*".equals(theResourceType) || StringUtils.isAlphanumeric(theResourceType); 171 } 172 173 /** 174 * Is this scope implied by theOtherScope. 175 * We need to know if requested scopes are permitted by the registered allowed scopes. 176 * This isn't just simple string-set membership since the allowed scopes might be patient/*.read 177 * while the requested scope could be patient/Observation.read, or patient/Observation.r 178 */ 179 public boolean isImpliedBy(SmartClinicalScope theOtherScope) { 180 return Objects.equals(theOtherScope.getPrefix(), getPrefix()) 181 && (theOtherScope.isStarType() || Objects.equals(theOtherScope.getResourceType(), getResourceType())) 182 && (theOtherScope.getV2Permissions().containsAll(this.getV2Permissions())) 183 && (!theOtherScope.hasFilter() || Objects.equals(theOtherScope.myFilter, myFilter)); 184 } 185 186 @Override 187 public String toString() { 188 return "SmartClinicalScope(" + getScopeString() + ")"; 189 } 190 191 @Nonnull 192 public String getScopeString() { 193 return myPrefix + "/" + myResourceType + "." + myPermissions.getPermissionString() 194 + (StringUtils.isNotEmpty(myFilter) ? ("?" + myFilter) : ""); 195 } 196 197 @Override 198 public boolean equals(Object theOther) { 199 if (this == theOther) { 200 return true; 201 } 202 if (theOther == null || getClass() != theOther.getClass()) { 203 return false; 204 } 205 SmartClinicalScope that = (SmartClinicalScope) theOther; 206 return myPrefix.equals(that.myPrefix) 207 && myResourceType.equals(that.myResourceType) 208 && myPermissions.equals(that.myPermissions) 209 && Objects.equals(myFilter, that.myFilter); 210 } 211 212 @Override 213 public int hashCode() { 214 return Objects.hash(myPrefix, myResourceType, myPermissions, myFilter); 215 } 216 217 public boolean hasSearch() { 218 return hasV2Permission(SmartV2Permission.SEARCH); 219 } 220 221 @Override 222 public int compareTo(@Nonnull SmartClinicalScope o) { 223 return toString().compareTo(o.toString()); 224 } 225}