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