001package ca.uhn.fhir.rest.server.interceptor.consent; 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.BaseRuntimeChildDefinition; 024import ca.uhn.fhir.context.BaseRuntimeElementDefinition; 025import ca.uhn.fhir.context.FhirContext; 026import ca.uhn.fhir.i18n.Msg; 027import ca.uhn.fhir.interceptor.api.Hook; 028import ca.uhn.fhir.interceptor.api.Interceptor; 029import ca.uhn.fhir.interceptor.api.Pointcut; 030import ca.uhn.fhir.rest.api.Constants; 031import ca.uhn.fhir.rest.api.server.IPreResourceAccessDetails; 032import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails; 033import ca.uhn.fhir.rest.api.server.RequestDetails; 034import ca.uhn.fhir.rest.api.server.ResponseDetails; 035import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; 036import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; 037import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 038import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationConstants; 039import ca.uhn.fhir.rest.server.util.ICachedSearchDetails; 040import ca.uhn.fhir.util.BundleUtil; 041import ca.uhn.fhir.util.IModelVisitor2; 042import org.apache.commons.lang3.Validate; 043import org.hl7.fhir.instance.model.api.IBase; 044import org.hl7.fhir.instance.model.api.IBaseBundle; 045import org.hl7.fhir.instance.model.api.IBaseExtension; 046import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 047import org.hl7.fhir.instance.model.api.IBaseResource; 048 049import java.util.ArrayList; 050import java.util.Arrays; 051import java.util.Collections; 052import java.util.IdentityHashMap; 053import java.util.List; 054import java.util.Map; 055import java.util.concurrent.atomic.AtomicInteger; 056import java.util.stream.Collectors; 057 058import static ca.uhn.fhir.rest.api.Constants.URL_TOKEN_METADATA; 059import static ca.uhn.fhir.rest.server.provider.ProviderConstants.OPERATION_META; 060 061/** 062 * The ConsentInterceptor can be used to apply arbitrary consent rules and data access policies 063 * on responses from a FHIR server. 064 * <p> 065 * See <a href="https://hapifhir.io/hapi-fhir/docs/security/consent_interceptor.html">Consent Interceptor</a> for 066 * more information on this interceptor. 067 * </p> 068 */ 069@Interceptor(order = AuthorizationConstants.ORDER_CONSENT_INTERCEPTOR) 070public class ConsentInterceptor { 071 private static final AtomicInteger ourInstanceCount = new AtomicInteger(0); 072 private final int myInstanceIndex = ourInstanceCount.incrementAndGet(); 073 private final String myRequestAuthorizedKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_AUTHORIZED"; 074 private final String myRequestCompletedKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_COMPLETED"; 075 private final String myRequestSeenResourcesKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_SEENRESOURCES"; 076 077 private volatile List<IConsentService> myConsentService = Collections.emptyList(); 078 private IConsentContextServices myContextConsentServices = IConsentContextServices.NULL_IMPL; 079 080 /** 081 * Constructor 082 */ 083 public ConsentInterceptor() { 084 super(); 085 } 086 087 /** 088 * Constructor 089 * 090 * @param theConsentService Must not be <code>null</code> 091 */ 092 public ConsentInterceptor(IConsentService theConsentService) { 093 this(theConsentService, IConsentContextServices.NULL_IMPL); 094 } 095 096 /** 097 * Constructor 098 * 099 * @param theConsentService Must not be <code>null</code> 100 * @param theContextConsentServices Must not be <code>null</code> 101 */ 102 public ConsentInterceptor(IConsentService theConsentService, IConsentContextServices theContextConsentServices) { 103 setConsentService(theConsentService); 104 setContextConsentServices(theContextConsentServices); 105 } 106 107 public void setContextConsentServices(IConsentContextServices theContextConsentServices) { 108 Validate.notNull(theContextConsentServices, "theContextConsentServices must not be null"); 109 myContextConsentServices = theContextConsentServices; 110 } 111 112 /** 113 * @deprecated Use {@link #registerConsentService(IConsentService)} instead 114 */ 115 @Deprecated 116 public void setConsentService(IConsentService theConsentService) { 117 Validate.notNull(theConsentService, "theConsentService must not be null"); 118 myConsentService = Collections.singletonList(theConsentService); 119 } 120 121 /** 122 * Adds a consent service to the chain. 123 * <p> 124 * Thread safety note: This method can be called while the service is actively processing requestes 125 * 126 * @param theConsentService The service to register. Must not be <code>null</code>. 127 * @since 6.0.0 128 */ 129 public ConsentInterceptor registerConsentService(IConsentService theConsentService) { 130 Validate.notNull(theConsentService, "theConsentService must not be null"); 131 List<IConsentService> newList = new ArrayList<>(myConsentService.size() + 1); 132 newList.addAll(myConsentService); 133 newList.add(theConsentService); 134 myConsentService = newList; 135 return this; 136 } 137 138 /** 139 * Removes a consent service from the chain. 140 * <p> 141 * Thread safety note: This method can be called while the service is actively processing requestes 142 * 143 * @param theConsentService The service to unregister. Must not be <code>null</code>. 144 * @since 6.0.0 145 */ 146 public ConsentInterceptor unregisterConsentService(IConsentService theConsentService) { 147 Validate.notNull(theConsentService, "theConsentService must not be null"); 148 List<IConsentService> newList = myConsentService 149 .stream() 150 .filter(t -> t != theConsentService) 151 .collect(Collectors.toList()); 152 myConsentService = newList; 153 return this; 154 } 155 156 @Hook(value = Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) 157 public void interceptPreHandled(RequestDetails theRequestDetails) { 158 if (isSkipServiceForRequest(theRequestDetails)) { 159 return; 160 } 161 162 validateParameter(theRequestDetails.getParameters()); 163 164 for (IConsentService nextService : myConsentService) { 165 ConsentOutcome outcome = nextService.startOperation(theRequestDetails, myContextConsentServices); 166 Validate.notNull(outcome, "Consent service returned null outcome"); 167 168 switch (outcome.getStatus()) { 169 case REJECT: 170 throw toForbiddenOperationException(outcome); 171 case PROCEED: 172 continue; 173 case AUTHORIZED: 174 Map<Object, Object> userData = theRequestDetails.getUserData(); 175 userData.put(myRequestAuthorizedKey, Boolean.TRUE); 176 return; 177 } 178 } 179 } 180 181 @Hook(value = Pointcut.STORAGE_PRECHECK_FOR_CACHED_SEARCH) 182 public boolean interceptPreCheckForCachedSearch(RequestDetails theRequestDetails) { 183 if (isRequestAuthorized(theRequestDetails)) { 184 return true; 185 } 186 return false; 187 } 188 189 @Hook(value = Pointcut.STORAGE_PRESEARCH_REGISTERED) 190 public void interceptPreSearchRegistered(RequestDetails theRequestDetails, ICachedSearchDetails theCachedSearchDetails) { 191 if (!isRequestAuthorized(theRequestDetails)) { 192 theCachedSearchDetails.setCannotBeReused(); 193 } 194 } 195 196 @Hook(value = Pointcut.STORAGE_PREACCESS_RESOURCES) 197 public void interceptPreAccess(RequestDetails theRequestDetails, IPreResourceAccessDetails thePreResourceAccessDetails) { 198 if (isRequestAuthorized(theRequestDetails)) { 199 return; 200 } 201 if (isSkipServiceForRequest(theRequestDetails)) { 202 return; 203 } 204 if (myConsentService.isEmpty()) { 205 return; 206 } 207 208 // First check if we should be calling canSeeResource for the individual 209 // consent services 210 boolean[] processConsentSvcs = new boolean[myConsentService.size()]; 211 boolean processAnyConsentSvcs = false; 212 for (int consentSvcIdx = 0; consentSvcIdx < myConsentService.size(); consentSvcIdx++) { 213 IConsentService nextService = myConsentService.get(consentSvcIdx); 214 215 boolean shouldCallCanSeeResource = nextService.shouldProcessCanSeeResource(theRequestDetails, myContextConsentServices); 216 processAnyConsentSvcs |= shouldCallCanSeeResource; 217 processConsentSvcs[consentSvcIdx] = shouldCallCanSeeResource; 218 } 219 220 if (!processAnyConsentSvcs) { 221 return; 222 } 223 224 IdentityHashMap<IBaseResource, Boolean> authorizedResources = getAuthorizedResourcesMap(theRequestDetails); 225 for (int resourceIdx = 0; resourceIdx < thePreResourceAccessDetails.size(); resourceIdx++) { 226 IBaseResource nextResource = thePreResourceAccessDetails.getResource(resourceIdx); 227 for (int consentSvcIdx = 0; consentSvcIdx < myConsentService.size(); consentSvcIdx++) { 228 IConsentService nextService = myConsentService.get(consentSvcIdx); 229 230 if (!processConsentSvcs[consentSvcIdx]) { 231 continue; 232 } 233 234 ConsentOutcome outcome = nextService.canSeeResource(theRequestDetails, nextResource, myContextConsentServices); 235 Validate.notNull(outcome, "Consent service returned null outcome"); 236 Validate.isTrue(outcome.getResource() == null, "Consent service returned a resource in its outcome. This is not permitted in canSeeResource(..)"); 237 238 boolean skipSubsequentServices = false; 239 switch (outcome.getStatus()) { 240 case PROCEED: 241 break; 242 case AUTHORIZED: 243 authorizedResources.put(nextResource, Boolean.TRUE); 244 skipSubsequentServices = true; 245 break; 246 case REJECT: 247 thePreResourceAccessDetails.setDontReturnResourceAtIndex(resourceIdx); 248 skipSubsequentServices = true; 249 break; 250 } 251 252 if (skipSubsequentServices) { 253 break; 254 } 255 } 256 } 257 } 258 259 @Hook(value = Pointcut.STORAGE_PRESHOW_RESOURCES) 260 public void interceptPreShow(RequestDetails theRequestDetails, IPreResourceShowDetails thePreResourceShowDetails) { 261 if (isRequestAuthorized(theRequestDetails)) { 262 return; 263 } 264 if (isAllowListedRequest(theRequestDetails)) { 265 return; 266 } 267 if (isSkipServiceForRequest(theRequestDetails)) { 268 return; 269 } 270 if (myConsentService.isEmpty()) { 271 return; 272 } 273 274 IdentityHashMap<IBaseResource, Boolean> authorizedResources = getAuthorizedResourcesMap(theRequestDetails); 275 276 for (int i = 0; i < thePreResourceShowDetails.size(); i++) { 277 278 IBaseResource resource = thePreResourceShowDetails.getResource(i); 279 if (resource == null || authorizedResources.putIfAbsent(resource, Boolean.TRUE) != null) { 280 continue; 281 } 282 283 for (IConsentService nextService : myConsentService) { 284 ConsentOutcome nextOutcome = nextService.willSeeResource(theRequestDetails, resource, myContextConsentServices); 285 IBaseResource newResource = nextOutcome.getResource(); 286 287 switch (nextOutcome.getStatus()) { 288 case PROCEED: 289 if (newResource != null) { 290 thePreResourceShowDetails.setResource(i, newResource); 291 resource = newResource; 292 } 293 continue; 294 case AUTHORIZED: 295 if (newResource != null) { 296 thePreResourceShowDetails.setResource(i, newResource); 297 } 298 continue; 299 case REJECT: 300 if (nextOutcome.getOperationOutcome() != null) { 301 IBaseOperationOutcome newOperationOutcome = nextOutcome.getOperationOutcome(); 302 thePreResourceShowDetails.setResource(i, newOperationOutcome); 303 authorizedResources.put(newOperationOutcome, true); 304 } else { 305 resource = null; 306 thePreResourceShowDetails.setResource(i, null); 307 } 308 continue; 309 } 310 } 311 } 312 } 313 314 @Hook(value = Pointcut.SERVER_OUTGOING_RESPONSE) 315 public void interceptOutgoingResponse(RequestDetails theRequestDetails, ResponseDetails theResource) { 316 if (theResource.getResponseResource() == null) { 317 return; 318 } 319 if (isRequestAuthorized(theRequestDetails)) { 320 return; 321 } 322 if (isAllowListedRequest(theRequestDetails)) { 323 return; 324 } 325 if (isSkipServiceForRequest(theRequestDetails)) { 326 return; 327 } 328 if (myConsentService.isEmpty()) { 329 return; 330 } 331 332 IdentityHashMap<IBaseResource, Boolean> authorizedResources = getAuthorizedResourcesMap(theRequestDetails); 333 334 // See outer resource 335 if (authorizedResources.putIfAbsent(theResource.getResponseResource(), Boolean.TRUE) == null) { 336 337 for (IConsentService next : myConsentService) { 338 final ConsentOutcome outcome = next.willSeeResource(theRequestDetails, theResource.getResponseResource(), myContextConsentServices); 339 if (outcome.getResource() != null) { 340 theResource.setResponseResource(outcome.getResource()); 341 } 342 343 // Clear the total 344 if (theResource.getResponseResource() instanceof IBaseBundle) { 345 BundleUtil.setTotal(theRequestDetails.getFhirContext(), (IBaseBundle) theResource.getResponseResource(), null); 346 } 347 348 switch (outcome.getStatus()) { 349 case REJECT: 350 if (outcome.getOperationOutcome() != null) { 351 theResource.setResponseResource(outcome.getOperationOutcome()); 352 } else { 353 theResource.setResponseResource(null); 354 theResource.setResponseCode(Constants.STATUS_HTTP_204_NO_CONTENT); 355 } 356 // Return immediately 357 return; 358 case AUTHORIZED: 359 // Don't check children, so return immediately 360 return; 361 case PROCEED: 362 // Check children, so proceed 363 break; 364 } 365 } 366 } 367 368 // See child resources 369 IBaseResource outerResource = theResource.getResponseResource(); 370 FhirContext ctx = theRequestDetails.getServer().getFhirContext(); 371 IModelVisitor2 visitor = new IModelVisitor2() { 372 @Override 373 public boolean acceptElement(IBase theElement, List<IBase> theContainingElementPath, List<BaseRuntimeChildDefinition> theChildDefinitionPath, List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) { 374 375 // Clear the total 376 if (theElement instanceof IBaseBundle) { 377 BundleUtil.setTotal(theRequestDetails.getFhirContext(), (IBaseBundle) theElement, null); 378 } 379 380 if (theElement == outerResource) { 381 return true; 382 } 383 if (theElement instanceof IBaseResource) { 384 IBaseResource resource = (IBaseResource) theElement; 385 if (authorizedResources.putIfAbsent(resource, Boolean.TRUE) != null) { 386 return true; 387 } 388 389 boolean shouldCheckChildren = true; 390 for (IConsentService next : myConsentService) { 391 ConsentOutcome childOutcome = next.willSeeResource(theRequestDetails, resource, myContextConsentServices); 392 393 IBaseResource replacementResource = null; 394 boolean shouldReplaceResource = false; 395 396 switch (childOutcome.getStatus()) { 397 case REJECT: 398 replacementResource = childOutcome.getOperationOutcome(); 399 shouldReplaceResource = true; 400 break; 401 case PROCEED: 402 case AUTHORIZED: 403 replacementResource = childOutcome.getResource(); 404 shouldReplaceResource = replacementResource != null; 405 shouldCheckChildren &= childOutcome.getStatus() == ConsentOperationStatusEnum.PROCEED; 406 break; 407 } 408 409 if (shouldReplaceResource) { 410 IBase container = theContainingElementPath.get(theContainingElementPath.size() - 2); 411 BaseRuntimeChildDefinition containerChildElement = theChildDefinitionPath.get(theChildDefinitionPath.size() - 1); 412 containerChildElement.getMutator().setValue(container, replacementResource); 413 resource = replacementResource; 414 } 415 416 } 417 418 return shouldCheckChildren; 419 } 420 421 return true; 422 } 423 424 @Override 425 public boolean acceptUndeclaredExtension(IBaseExtension<?, ?> theNextExt, List<IBase> theContainingElementPath, List<BaseRuntimeChildDefinition> theChildDefinitionPath, List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) { 426 return true; 427 } 428 }; 429 ctx.newTerser().visit(outerResource, visitor); 430 431 } 432 433 private IdentityHashMap<IBaseResource, Boolean> getAuthorizedResourcesMap(RequestDetails theRequestDetails) { 434 return getAlreadySeenResourcesMap(theRequestDetails, myRequestSeenResourcesKey); 435 } 436 437 @Hook(value = Pointcut.SERVER_HANDLE_EXCEPTION) 438 public void requestFailed(RequestDetails theRequest, BaseServerResponseException theException) { 439 theRequest.getUserData().put(myRequestCompletedKey, Boolean.TRUE); 440 for (IConsentService next : myConsentService) { 441 next.completeOperationFailure(theRequest, theException, myContextConsentServices); 442 } 443 } 444 445 @Hook(value = Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY) 446 public void requestSucceeded(RequestDetails theRequest) { 447 if (Boolean.TRUE.equals(theRequest.getUserData().get(myRequestCompletedKey))) { 448 return; 449 } 450 for (IConsentService next : myConsentService) { 451 next.completeOperationSuccess(theRequest, myContextConsentServices); 452 } 453 } 454 455 private boolean isRequestAuthorized(RequestDetails theRequestDetails) { 456 boolean retVal = false; 457 if (theRequestDetails != null) { 458 Object authorizedObj = theRequestDetails.getUserData().get(myRequestAuthorizedKey); 459 retVal = Boolean.TRUE.equals(authorizedObj); 460 } 461 return retVal; 462 } 463 464 private boolean isSkipServiceForRequest(RequestDetails theRequestDetails) { 465 return isMetadataPath(theRequestDetails) || isMetaOperation(theRequestDetails); 466 } 467 468 private boolean isAllowListedRequest(RequestDetails theRequestDetails) { 469 return isMetadataPath(theRequestDetails) || isMetaOperation(theRequestDetails); 470 } 471 472 private boolean isMetaOperation(RequestDetails theRequestDetails) { 473 return OPERATION_META.equals(theRequestDetails.getOperation()); 474 } 475 476 private boolean isMetadataPath(RequestDetails theRequestDetails) { 477 return URL_TOKEN_METADATA.equals(theRequestDetails.getRequestPath()); 478 } 479 480 private void validateParameter(Map<String, String[]> theParameterMap) { 481 if (theParameterMap != null) { 482 if (theParameterMap.containsKey(Constants.PARAM_SEARCH_TOTAL_MODE) && Arrays.stream(theParameterMap.get("_total")).anyMatch("accurate"::equals)) { 483 throw new InvalidRequestException(Msg.code(2037) + Constants.PARAM_SEARCH_TOTAL_MODE + "=accurate is not permitted on this server"); 484 } 485 if (theParameterMap.containsKey(Constants.PARAM_SUMMARY) && Arrays.stream(theParameterMap.get("_summary")).anyMatch("count"::equals)) { 486 throw new InvalidRequestException(Msg.code(2038) + Constants.PARAM_SUMMARY + "=count is not permitted on this server"); 487 } 488 } 489 } 490 491 @SuppressWarnings("unchecked") 492 public static IdentityHashMap<IBaseResource, Boolean> getAlreadySeenResourcesMap(RequestDetails theRequestDetails, String theKey) { 493 IdentityHashMap<IBaseResource, Boolean> alreadySeenResources = (IdentityHashMap<IBaseResource, Boolean>) theRequestDetails.getUserData().get(theKey); 494 if (alreadySeenResources == null) { 495 alreadySeenResources = new IdentityHashMap<>(); 496 theRequestDetails.getUserData().put(theKey, alreadySeenResources); 497 } 498 return alreadySeenResources; 499 } 500 501 private static ForbiddenOperationException toForbiddenOperationException(ConsentOutcome theOutcome) { 502 IBaseOperationOutcome operationOutcome = null; 503 if (theOutcome.getOperationOutcome() != null) { 504 operationOutcome = theOutcome.getOperationOutcome(); 505 } 506 return new ForbiddenOperationException("Rejected by consent service", operationOutcome); 507 } 508}