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