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