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}