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}