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