
001/* 002 * #%L 003 * HAPI FHIR - Server Framework 004 * %% 005 * Copyright (C) 2014 - 2023 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.rest.server.interceptor.auth; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.support.IValidationSupport; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.Hook; 026import ca.uhn.fhir.interceptor.api.Interceptor; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 029import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails; 030import ca.uhn.fhir.rest.api.server.RequestDetails; 031import ca.uhn.fhir.rest.api.server.bulk.BulkDataExportOptions; 032import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; 033import ca.uhn.fhir.rest.server.interceptor.consent.ConsentInterceptor; 034import com.google.common.collect.Lists; 035import org.apache.commons.lang3.StringUtils; 036import org.apache.commons.lang3.Validate; 037import org.apache.commons.lang3.builder.ToStringBuilder; 038import org.apache.commons.lang3.builder.ToStringStyle; 039import org.hl7.fhir.instance.model.api.IBaseBundle; 040import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 041import org.hl7.fhir.instance.model.api.IBaseParameters; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.hl7.fhir.instance.model.api.IIdType; 044import org.slf4j.Logger; 045import org.slf4j.LoggerFactory; 046 047import javax.annotation.Nonnull; 048import javax.annotation.Nullable; 049import java.util.ArrayList; 050import java.util.Collection; 051import java.util.Collections; 052import java.util.HashSet; 053import java.util.IdentityHashMap; 054import java.util.List; 055import java.util.Objects; 056import java.util.Set; 057import java.util.concurrent.atomic.AtomicInteger; 058 059import static java.util.Objects.isNull; 060import static java.util.Objects.nonNull; 061import static org.apache.commons.lang3.StringUtils.EMPTY; 062import static org.apache.commons.lang3.StringUtils.defaultString; 063import static org.apache.commons.lang3.StringUtils.isNotBlank; 064 065/** 066 * This class is a base class for interceptors which can be used to 067 * inspect requests and responses to determine whether the calling user 068 * has permission to perform the given action. 069 * <p> 070 * See the HAPI FHIR 071 * <a href="https://hapifhir.io/hapi-fhir/docs/security/introduction.html">Documentation on Server Security</a> 072 * for information on how to use this interceptor. 073 * </p> 074 * 075 * @see SearchNarrowingInterceptor 076 */ 077@Interceptor(order = AuthorizationConstants.ORDER_AUTH_INTERCEPTOR) 078public class AuthorizationInterceptor implements IRuleApplier { 079 080 public static final String REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS = AuthorizationInterceptor.class.getName() + "_BulkDataExportOptions"; 081 private static final AtomicInteger ourInstanceCount = new AtomicInteger(0); 082 private static final Logger ourLog = LoggerFactory.getLogger(AuthorizationInterceptor.class); 083 private final int myInstanceIndex = ourInstanceCount.incrementAndGet(); 084 private final String myRequestSeenResourcesKey = AuthorizationInterceptor.class.getName() + "_" + myInstanceIndex + "_SEENRESOURCES"; 085 private final String myRequestRuleListKey = AuthorizationInterceptor.class.getName() + "_" + myInstanceIndex + "_RULELIST"; 086 private PolicyEnum myDefaultPolicy = PolicyEnum.DENY; 087 private Set<AuthorizationFlagsEnum> myFlags = Collections.emptySet(); 088 private IValidationSupport myValidationSupport; 089 090 private IAuthorizationSearchParamMatcher myAuthorizationSearchParamMatcher; 091 private Logger myTroubleshootingLog; 092 093 /** 094 * Constructor 095 */ 096 public AuthorizationInterceptor() { 097 super(); 098 setTroubleshootingLog(ourLog); 099 } 100 101 /** 102 * Constructor 103 * 104 * @param theDefaultPolicy The default policy if no rules apply (must not be null) 105 */ 106 public AuthorizationInterceptor(PolicyEnum theDefaultPolicy) { 107 this(); 108 setDefaultPolicy(theDefaultPolicy); 109 } 110 111 @Nonnull 112 @Override 113 public Logger getTroubleshootingLog() { 114 return myTroubleshootingLog; 115 } 116 117 public void setTroubleshootingLog(@Nonnull Logger theTroubleshootingLog) { 118 Validate.notNull(theTroubleshootingLog, "theTroubleshootingLog must not be null"); 119 myTroubleshootingLog = theTroubleshootingLog; 120 } 121 122 private void applyRulesAndFailIfDeny(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, 123 IBaseResource theOutputResource, Pointcut thePointcut) { 124 Verdict decision = applyRulesAndReturnDecision(theOperation, theRequestDetails, theInputResource, theInputResourceId, theOutputResource, thePointcut); 125 126 if (decision.getDecision() == PolicyEnum.ALLOW) { 127 return; 128 } 129 130 handleDeny(theRequestDetails, decision); 131 } 132 133 @Override 134 public Verdict applyRulesAndReturnDecision(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, 135 IBaseResource theOutputResource, Pointcut thePointcut) { 136 @SuppressWarnings("unchecked") 137 List<IAuthRule> rules = (List<IAuthRule>) theRequestDetails.getUserData().get(myRequestRuleListKey); 138 if (rules == null) { 139 rules = buildRuleList(theRequestDetails); 140 theRequestDetails.getUserData().put(myRequestRuleListKey, rules); 141 } 142 Set<AuthorizationFlagsEnum> flags = getFlags(); 143 144 ourLog.trace("Applying {} rules to render an auth decision for operation {}, theInputResource type={}, theOutputResource type={}, thePointcut={} ", 145 rules.size(), 146 getPointcutNameOrEmpty(thePointcut), 147 getResourceTypeOrEmpty(theInputResource), 148 getResourceTypeOrEmpty(theOutputResource)); 149 150 Verdict verdict = null; 151 for (IAuthRule nextRule : rules) { 152 ourLog.trace("Rule being applied - {}", nextRule); 153 verdict = nextRule.applyRule(theOperation, theRequestDetails, theInputResource, theInputResourceId, theOutputResource, this, flags, thePointcut); 154 if (verdict != null) { 155 ourLog.trace("Rule {} returned decision {}", nextRule, verdict.getDecision()); 156 break; 157 } 158 } 159 160 if (verdict == null) { 161 ourLog.trace("No rules returned a decision, applying default {}", myDefaultPolicy); 162 return new Verdict(getDefaultPolicy(), null); 163 } 164 165 return verdict; 166 } 167 168 /** 169 * @since 6.0.0 170 */ 171 @Nullable 172 @Override 173 public IValidationSupport getValidationSupport() { 174 return myValidationSupport; 175 } 176 177 /** 178 * Sets a validation support module that will be used for terminology-based rules 179 * 180 * @param theValidationSupport The validation support. Null is also acceptable (this is the default), 181 * in which case the validation support module associated with the {@link FhirContext} 182 * will be used. 183 * @since 6.0.0 184 */ 185 public AuthorizationInterceptor setValidationSupport(IValidationSupport theValidationSupport) { 186 myValidationSupport = theValidationSupport; 187 return this; 188 } 189 190 /** 191 * Sets a search parameter matcher for use in handling SMART v2 filter scopes 192 * 193 * @param theAuthorizationSearchParamMatcher The search parameter matcher. Defaults to null. 194 */ 195 public void setAuthorizationSearchParamMatcher(@Nullable IAuthorizationSearchParamMatcher theAuthorizationSearchParamMatcher) { 196 this.myAuthorizationSearchParamMatcher = theAuthorizationSearchParamMatcher; 197 } 198 199 @Nullable 200 public IAuthorizationSearchParamMatcher getSearchParamMatcher() { 201 return myAuthorizationSearchParamMatcher; 202 } 203 204 /** 205 * Subclasses should override this method to supply the set of rules to be applied to 206 * this individual request. 207 * <p> 208 * Typically this is done by examining <code>theRequestDetails</code> to find 209 * out who the current user is and then using a {@link RuleBuilder} to create 210 * an appropriate rule chain. 211 * </p> 212 * 213 * @param theRequestDetails The individual request currently being applied 214 */ 215 public List<IAuthRule> buildRuleList(RequestDetails theRequestDetails) { 216 return new ArrayList<>(); 217 } 218 219 private OperationExamineDirection determineOperationDirection(RestOperationTypeEnum theOperation) { 220 221 switch (theOperation) { 222 case ADD_TAGS: 223 case DELETE_TAGS: 224 case GET_TAGS: 225 // These are DSTU1 operations and not relevant 226 return OperationExamineDirection.NONE; 227 228 case EXTENDED_OPERATION_INSTANCE: 229 case EXTENDED_OPERATION_SERVER: 230 case EXTENDED_OPERATION_TYPE: 231 return OperationExamineDirection.BOTH; 232 233 case METADATA: 234 // Security does not apply to these operations 235 return OperationExamineDirection.IN; 236 237 case DELETE: 238 // Delete is a special case 239 return OperationExamineDirection.IN; 240 241 case CREATE: 242 case UPDATE: 243 case PATCH: 244 return OperationExamineDirection.IN; 245 246 case META: 247 case META_ADD: 248 case META_DELETE: 249 // meta operations do not apply yet 250 return OperationExamineDirection.NONE; 251 252 case GET_PAGE: 253 case HISTORY_INSTANCE: 254 case HISTORY_SYSTEM: 255 case HISTORY_TYPE: 256 case READ: 257 case SEARCH_SYSTEM: 258 case SEARCH_TYPE: 259 case VREAD: 260 return OperationExamineDirection.OUT; 261 262 case TRANSACTION: 263 return OperationExamineDirection.BOTH; 264 265 case VALIDATE: 266 // Nothing yet 267 return OperationExamineDirection.NONE; 268 269 case GRAPHQL_REQUEST: 270 return OperationExamineDirection.BOTH; 271 272 default: 273 // Should not happen 274 throw new IllegalStateException(Msg.code(332) + "Unable to apply security to event of type " + theOperation); 275 } 276 277 } 278 279 /** 280 * The default policy if no rules have been found to apply. Default value for this setting is {@link PolicyEnum#DENY} 281 */ 282 public PolicyEnum getDefaultPolicy() { 283 return myDefaultPolicy; 284 } 285 286 /** 287 * The default policy if no rules have been found to apply. Default value for this setting is {@link PolicyEnum#DENY} 288 * 289 * @param theDefaultPolicy The policy (must not be <code>null</code>) 290 */ 291 public AuthorizationInterceptor setDefaultPolicy(PolicyEnum theDefaultPolicy) { 292 Validate.notNull(theDefaultPolicy, "theDefaultPolicy must not be null"); 293 myDefaultPolicy = theDefaultPolicy; 294 return this; 295 } 296 297 /** 298 * This property configures any flags affecting how authorization is 299 * applied. By default no flags are applied. 300 * 301 * @see #setFlags(Collection) 302 */ 303 public Set<AuthorizationFlagsEnum> getFlags() { 304 return Collections.unmodifiableSet(myFlags); 305 } 306 307 /** 308 * This property configures any flags affecting how authorization is 309 * applied. By default no flags are applied. 310 * 311 * @param theFlags The flags (must not be null) 312 * @see #setFlags(AuthorizationFlagsEnum...) 313 */ 314 public AuthorizationInterceptor setFlags(Collection<AuthorizationFlagsEnum> theFlags) { 315 Validate.notNull(theFlags, "theFlags must not be null"); 316 myFlags = new HashSet<>(theFlags); 317 return this; 318 } 319 320 /** 321 * This property configures any flags affecting how authorization is 322 * applied. By default no flags are applied. 323 * 324 * @param theFlags The flags (must not be null) 325 * @see #setFlags(Collection) 326 */ 327 public AuthorizationInterceptor setFlags(AuthorizationFlagsEnum... theFlags) { 328 Validate.notNull(theFlags, "theFlags must not be null"); 329 return setFlags(Lists.newArrayList(theFlags)); 330 } 331 332 /** 333 * Handle an access control verdict of {@link PolicyEnum#DENY}. 334 * <p> 335 * Subclasses may override to implement specific behaviour, but default is to 336 * throw {@link ForbiddenOperationException} (HTTP 403) with error message citing the 337 * rule name which trigered failure 338 * </p> 339 * 340 * @since HAPI FHIR 3.6.0 341 */ 342 protected void handleDeny(RequestDetails theRequestDetails, Verdict decision) { 343 handleDeny(decision); 344 } 345 346 /** 347 * This method should not be overridden. As of HAPI FHIR 3.6.0, you 348 * should override {@link #handleDeny(RequestDetails, Verdict)} instead. This 349 * method will be removed in the future. 350 */ 351 protected void handleDeny(Verdict decision) { 352 if (decision.getDecidingRule() != null) { 353 String ruleName = defaultString(decision.getDecidingRule().getName(), "(unnamed rule)"); 354 throw new ForbiddenOperationException(Msg.code(333) + "Access denied by rule: " + ruleName); 355 } 356 throw new ForbiddenOperationException(Msg.code(334) + "Access denied by default policy (no applicable rules)"); 357 } 358 359 private void handleUserOperation(RequestDetails theRequest, IBaseResource theResource, RestOperationTypeEnum theOperation, Pointcut thePointcut) { 360 applyRulesAndFailIfDeny(theOperation, theRequest, theResource, theResource.getIdElement(), null, thePointcut); 361 } 362 363 @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) 364 public void incomingRequestPreHandled(RequestDetails theRequest, Pointcut thePointcut) { 365 IBaseResource inputResource = null; 366 IIdType inputResourceId = null; 367 368 switch (determineOperationDirection(theRequest.getRestOperationType())) { 369 case IN: 370 case BOTH: 371 inputResource = theRequest.getResource(); 372 inputResourceId = theRequest.getId(); 373 if (inputResourceId == null && isNotBlank(theRequest.getResourceName())) { 374 inputResourceId = theRequest.getFhirContext().getVersion().newIdType(); 375 inputResourceId.setParts(null, theRequest.getResourceName(), null, null); 376 } 377 break; 378 case OUT: 379 // inputResource = null; 380 inputResourceId = theRequest.getId(); 381 break; 382 case NONE: 383 return; 384 } 385 386 applyRulesAndFailIfDeny(theRequest.getRestOperationType(), theRequest, inputResource, inputResourceId, null, thePointcut); 387 } 388 389 @Hook(Pointcut.STORAGE_PRESHOW_RESOURCES) 390 public void hookPreShow(RequestDetails theRequestDetails, IPreResourceShowDetails theDetails, Pointcut thePointcut) { 391 for (int i = 0; i < theDetails.size(); i++) { 392 IBaseResource next = theDetails.getResource(i); 393 checkOutgoingResourceAndFailIfDeny(theRequestDetails, next, thePointcut); 394 } 395 } 396 397 @Hook(Pointcut.SERVER_OUTGOING_RESPONSE) 398 public void hookOutgoingResponse(RequestDetails theRequestDetails, IBaseResource theResponseObject, Pointcut thePointcut) { 399 checkOutgoingResourceAndFailIfDeny(theRequestDetails, theResponseObject, thePointcut); 400 } 401 402 @Hook(Pointcut.STORAGE_CASCADE_DELETE) 403 public void hookCascadeDeleteForConflict(RequestDetails theRequestDetails, Pointcut thePointcut, IBaseResource theResourceToDelete) { 404 Validate.notNull(theResourceToDelete); // just in case 405 checkPointcutAndFailIfDeny(theRequestDetails, thePointcut, theResourceToDelete); 406 } 407 408 @Hook(Pointcut.STORAGE_PRE_DELETE_EXPUNGE) 409 public void hookDeleteExpunge(RequestDetails theRequestDetails, Pointcut thePointcut) { 410 applyRulesAndFailIfDeny(theRequestDetails.getRestOperationType(), theRequestDetails, null, null, null, thePointcut); 411 } 412 413 @Hook(Pointcut.STORAGE_INITIATE_BULK_EXPORT) 414 public void initiateBulkExport(RequestDetails theRequestDetails, BulkDataExportOptions theBulkExportOptions, Pointcut thePointcut) { 415 RestOperationTypeEnum restOperationType = RestOperationTypeEnum.EXTENDED_OPERATION_SERVER; 416 if (theRequestDetails != null) { 417 theRequestDetails.setAttribute(REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS, theBulkExportOptions); 418 } 419 applyRulesAndFailIfDeny(restOperationType, theRequestDetails, null, null, null, thePointcut); 420 } 421 422 private void checkPointcutAndFailIfDeny(RequestDetails theRequestDetails, Pointcut thePointcut, @Nonnull IBaseResource theInputResource) { 423 applyRulesAndFailIfDeny(theRequestDetails.getRestOperationType(), theRequestDetails, theInputResource, theInputResource.getIdElement(), null, thePointcut); 424 } 425 426 private void checkOutgoingResourceAndFailIfDeny(RequestDetails theRequestDetails, IBaseResource theResponseObject, Pointcut thePointcut) { 427 428 switch (determineOperationDirection(theRequestDetails.getRestOperationType())) { 429 case IN: 430 case NONE: 431 return; 432 case BOTH: 433 case OUT: 434 break; 435 } 436 437 // Don't check the value twice 438 IdentityHashMap<IBaseResource, Boolean> alreadySeenMap = ConsentInterceptor.getAlreadySeenResourcesMap(theRequestDetails, myRequestSeenResourcesKey); 439 if (alreadySeenMap.putIfAbsent(theResponseObject, Boolean.TRUE) != null) { 440 return; 441 } 442 443 FhirContext fhirContext = theRequestDetails.getServer().getFhirContext(); 444 List<IBaseResource> resources = Collections.emptyList(); 445 446 //noinspection EnumSwitchStatementWhichMissesCases 447 switch (theRequestDetails.getRestOperationType()) { 448 case SEARCH_SYSTEM: 449 case SEARCH_TYPE: 450 case HISTORY_INSTANCE: 451 case HISTORY_SYSTEM: 452 case HISTORY_TYPE: 453 case TRANSACTION: 454 case GET_PAGE: 455 case EXTENDED_OPERATION_SERVER: 456 case EXTENDED_OPERATION_TYPE: 457 case EXTENDED_OPERATION_INSTANCE: { 458 if (theResponseObject != null) { 459 resources = toListOfResourcesAndExcludeContainer(theResponseObject, fhirContext); 460 } 461 break; 462 } 463 default: { 464 if (theResponseObject != null) { 465 resources = Collections.singletonList(theResponseObject); 466 } 467 break; 468 } 469 } 470 471 for (IBaseResource nextResponse : resources) { 472 applyRulesAndFailIfDeny(theRequestDetails.getRestOperationType(), theRequestDetails, null, null, nextResponse, thePointcut); 473 } 474 } 475 476 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) 477 public void hookResourcePreCreate(RequestDetails theRequest, IBaseResource theResource, Pointcut thePointcut) { 478 handleUserOperation(theRequest, theResource, RestOperationTypeEnum.CREATE, thePointcut); 479 } 480 481 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_DELETED) 482 public void hookResourcePreDelete(RequestDetails theRequest, IBaseResource theResource, Pointcut thePointcut) { 483 handleUserOperation(theRequest, theResource, RestOperationTypeEnum.DELETE, thePointcut); 484 } 485 486 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) 487 public void hookResourcePreUpdate(RequestDetails theRequest, IBaseResource theOldResource, IBaseResource theNewResource, Pointcut thePointcut) { 488 if (theOldResource != null) { 489 handleUserOperation(theRequest, theOldResource, RestOperationTypeEnum.UPDATE, thePointcut); 490 } 491 handleUserOperation(theRequest, theNewResource, RestOperationTypeEnum.UPDATE, thePointcut); 492 } 493 494 private enum OperationExamineDirection { 495 BOTH, 496 IN, 497 NONE, 498 OUT, 499 } 500 501 static List<IBaseResource> toListOfResourcesAndExcludeContainer(IBaseResource theResponseObject, FhirContext fhirContext) { 502 if (theResponseObject == null) { 503 return Collections.emptyList(); 504 } 505 506 List<IBaseResource> retVal; 507 508 boolean isContainer = false; 509 if (theResponseObject instanceof IBaseBundle) { 510 isContainer = true; 511 } else if (theResponseObject instanceof IBaseParameters) { 512 isContainer = true; 513 } 514 515 if (!isContainer) { 516 return Collections.singletonList(theResponseObject); 517 } 518 519 retVal = fhirContext.newTerser().getAllPopulatedChildElementsOfType(theResponseObject, IBaseResource.class); 520 521 // Exclude the container 522 if (retVal.size() > 0 && retVal.get(0) == theResponseObject) { 523 retVal = retVal.subList(1, retVal.size()); 524 } 525 526 // Don't apply security to OperationOutcome 527 retVal.removeIf(t->t instanceof IBaseOperationOutcome); 528 529 return retVal; 530 } 531 532 public static class Verdict { 533 534 private final IAuthRule myDecidingRule; 535 private final PolicyEnum myDecision; 536 537 public Verdict(PolicyEnum theDecision, IAuthRule theDecidingRule) { 538 Validate.notNull(theDecision); 539 540 myDecision = theDecision; 541 myDecidingRule = theDecidingRule; 542 } 543 544 IAuthRule getDecidingRule() { 545 return myDecidingRule; 546 } 547 548 public PolicyEnum getDecision() { 549 return myDecision; 550 } 551 552 @Override 553 public String toString() { 554 ToStringBuilder b = new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE); 555 String ruleName; 556 if (myDecidingRule != null) { 557 ruleName = myDecidingRule.getName(); 558 } else { 559 ruleName = "(none)"; 560 } 561 b.append("rule", ruleName); 562 b.append("decision", myDecision.name()); 563 return b.build(); 564 } 565 566 } 567 568 private Object getPointcutNameOrEmpty(Pointcut thePointcut) { 569 return nonNull(thePointcut) ? thePointcut.name() : EMPTY; 570 } 571 572 private String getResourceTypeOrEmpty(IBaseResource theResource){ 573 String retVal = StringUtils.EMPTY; 574 575 if(isNull(theResource)){ 576 return retVal; 577 } 578 579 if(isNull(theResource.getIdElement())){ 580 return retVal; 581 } 582 583 if(isNull(theResource.getIdElement().getResourceType())){ 584 return retVal; 585 } 586 587 return theResource.getIdElement().getResourceType(); 588 } 589 590}