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.auth; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeResourceDefinition; 024import ca.uhn.fhir.context.RuntimeSearchParam; 025import ca.uhn.fhir.context.support.IValidationSupport; 026import ca.uhn.fhir.context.support.ValidationSupportContext; 027import ca.uhn.fhir.context.support.ValueSetExpansionOptions; 028import ca.uhn.fhir.i18n.Msg; 029import ca.uhn.fhir.interceptor.api.Hook; 030import ca.uhn.fhir.interceptor.api.Pointcut; 031import ca.uhn.fhir.rest.api.Constants; 032import ca.uhn.fhir.rest.api.QualifiedParamList; 033import ca.uhn.fhir.rest.api.RequestTypeEnum; 034import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 035import ca.uhn.fhir.rest.api.server.RequestDetails; 036import ca.uhn.fhir.rest.param.ParameterUtil; 037import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; 038import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; 039import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 040import ca.uhn.fhir.util.BundleUtil; 041import ca.uhn.fhir.util.FhirTerser; 042import ca.uhn.fhir.util.UrlUtil; 043import ca.uhn.fhir.util.ValidateUtil; 044import ca.uhn.fhir.util.bundle.ModifiableBundleEntry; 045import com.google.common.collect.ListMultimap; 046import com.google.common.collect.MultimapBuilder; 047import jakarta.annotation.Nonnull; 048import jakarta.annotation.Nullable; 049import jakarta.servlet.http.HttpServletRequest; 050import jakarta.servlet.http.HttpServletResponse; 051import org.apache.commons.collections4.ListUtils; 052import org.apache.commons.lang3.StringUtils; 053import org.apache.commons.lang3.Validate; 054import org.hl7.fhir.instance.model.api.IBase; 055import org.hl7.fhir.instance.model.api.IBaseBundle; 056 057import java.util.ArrayList; 058import java.util.Arrays; 059import java.util.Collection; 060import java.util.HashMap; 061import java.util.List; 062import java.util.Map; 063import java.util.Optional; 064import java.util.Set; 065import java.util.function.Consumer; 066import java.util.stream.Collectors; 067 068import static org.apache.commons.lang3.StringUtils.isNotBlank; 069 070/** 071 * This interceptor can be used to automatically narrow the scope of searches in order to 072 * automatically restrict the searches to specific compartments. 073 * <p> 074 * For example, this interceptor 075 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data 076 * in the <code>Patient/123</code> compartment). In this case, a user performing a search 077 * for<br/> 078 * <code>http://baseurl/Observation?category=laboratory</code><br/> 079 * would receive results as though they had requested<br/> 080 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code> 081 * </p> 082 * <p> 083 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor} 084 * if you are restricting results because of a security restriction. This interceptor is not 085 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the 086 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to 087 * help users simplify their queries while not receiving security errors for to trying to access 088 * data they do not have access to see. 089 * </p> 090 * 091 * @see AuthorizationInterceptor 092 */ 093@SuppressWarnings("JavadocLinkAsPlainText") 094public class SearchNarrowingInterceptor { 095 096 public static final String POST_FILTERING_LIST_ATTRIBUTE_NAME = 097 SearchNarrowingInterceptor.class.getName() + "_POST_FILTERING_LIST"; 098 private IValidationSupport myValidationSupport; 099 private int myPostFilterLargeValueSetThreshold = 500; 100 private boolean myNarrowConditionalUrls; 101 102 /** 103 * If set to {@literal true} (default is {@literal false}), conditional URLs such 104 * as the If-None-Exist header used for Conditional Create operations will 105 * also be narrowed. 106 * 107 * @param theNarrowConditionalUrls Should we narrow conditional URLs in requests 108 * @since 7.2.0 109 */ 110 public void setNarrowConditionalUrls(boolean theNarrowConditionalUrls) { 111 myNarrowConditionalUrls = theNarrowConditionalUrls; 112 } 113 114 /** 115 * Supplies a threshold over which any ValueSet-based rules will be applied by 116 * 117 * 118 * <p> 119 * Note that this setting will have no effect if {@link #setValidationSupport(IValidationSupport)} 120 * has not also been called in order to supply a validation support module for 121 * testing ValueSet membership. 122 * </p> 123 * 124 * @param thePostFilterLargeValueSetThreshold The threshold 125 * @see #setValidationSupport(IValidationSupport) 126 */ 127 public void setPostFilterLargeValueSetThreshold(int thePostFilterLargeValueSetThreshold) { 128 Validate.isTrue( 129 thePostFilterLargeValueSetThreshold > 0, 130 "thePostFilterLargeValueSetThreshold must be a positive integer"); 131 myPostFilterLargeValueSetThreshold = thePostFilterLargeValueSetThreshold; 132 } 133 134 /** 135 * Supplies a validation support module that will be used to apply the 136 * 137 * @see #setPostFilterLargeValueSetThreshold(int) 138 * @since 6.0.0 139 */ 140 public SearchNarrowingInterceptor setValidationSupport(IValidationSupport theValidationSupport) { 141 myValidationSupport = theValidationSupport; 142 return this; 143 } 144 145 /** 146 * This method handles narrowing for FHIR search/create/update/patch operations. 147 * 148 * @see #hookIncomingRequestPreHandled(ServletRequestDetails, HttpServletRequest, HttpServletResponse) This method narrows FHIR transaction bundles 149 */ 150 @SuppressWarnings("EnumSwitchStatementWhichMissesCases") 151 @Hook(Pointcut.SERVER_INCOMING_REQUEST_POST_PROCESSED) 152 public void hookIncomingRequestPostProcessed( 153 RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) 154 throws AuthenticationException { 155 156 // We don't support this operation type yet 157 RestOperationTypeEnum restOperationType = theRequestDetails.getRestOperationType(); 158 Validate.isTrue(restOperationType != RestOperationTypeEnum.SEARCH_SYSTEM); 159 160 switch (restOperationType) { 161 case EXTENDED_OPERATION_INSTANCE: 162 case EXTENDED_OPERATION_TYPE: { 163 if ("$everything".equals(theRequestDetails.getOperation())) { 164 narrowEverythingOperation(theRequestDetails); 165 } 166 break; 167 } 168 case SEARCH_TYPE: 169 narrowTypeSearch(theRequestDetails); 170 break; 171 case CREATE: 172 narrowIfNoneExistHeader(theRequestDetails); 173 break; 174 case DELETE: 175 case UPDATE: 176 case PATCH: 177 narrowRequestUrl(theRequestDetails, restOperationType); 178 break; 179 } 180 } 181 182 /** 183 * This method narrows FHIR transaction operations (because this pointcut 184 * is called after the request body is parsed). 185 * 186 * @see #hookIncomingRequestPostProcessed(RequestDetails, HttpServletRequest, HttpServletResponse) This method narrows FHIR search/create/update/etc operations 187 */ 188 @SuppressWarnings("EnumSwitchStatementWhichMissesCases") 189 @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) 190 public void hookIncomingRequestPreHandled( 191 ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) 192 throws AuthenticationException { 193 194 if (theRequestDetails.getRestOperationType() != null) { 195 switch (theRequestDetails.getRestOperationType()) { 196 case TRANSACTION: 197 case BATCH: 198 IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource(); 199 FhirContext ctx = theRequestDetails.getFhirContext(); 200 BundleEntryUrlProcessor processor = new BundleEntryUrlProcessor(ctx, theRequestDetails); 201 BundleUtil.processEntries(ctx, bundle, processor); 202 break; 203 } 204 } 205 } 206 207 /** 208 * Subclasses should override this method to supply the set of compartments that 209 * the user making the request should actually have access to. 210 * <p> 211 * Typically this is done by examining <code>theRequestDetails</code> to find 212 * out who the current user is and then building a list of Strings. 213 * </p> 214 * 215 * @param theRequestDetails The individual request currently being applied 216 * @return The list of allowed compartments and instances that should be used 217 * for search narrowing. If this method returns <code>null</code>, no narrowing will 218 * be performed 219 */ 220 protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) { 221 return null; 222 } 223 224 /** 225 * For the $everything operation, we only do code narrowing, and in this case 226 * we're not actually even making any changes to the request. All we do here is 227 * ensure that an attribute is added to the request, which is picked up later 228 * by {@link SearchNarrowingConsentService}. 229 */ 230 private void narrowEverythingOperation(RequestDetails theRequestDetails) { 231 AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails); 232 if (authorizedList != null) { 233 buildParameterListForAuthorizedCodes( 234 theRequestDetails, theRequestDetails.getResourceName(), authorizedList); 235 } 236 } 237 238 private void narrowIfNoneExistHeader(RequestDetails theRequestDetails) { 239 if (myNarrowConditionalUrls) { 240 String ifNoneExist = theRequestDetails.getHeader(Constants.HEADER_IF_NONE_EXIST); 241 if (isNotBlank(ifNoneExist)) { 242 String newConditionalUrl = narrowConditionalUrlForCompartmentOnly( 243 theRequestDetails, ifNoneExist, true, theRequestDetails.getResourceName()); 244 if (newConditionalUrl != null) { 245 theRequestDetails.setHeaders(Constants.HEADER_IF_NONE_EXIST, List.of(newConditionalUrl)); 246 } 247 } 248 } 249 } 250 251 private void narrowRequestUrl(RequestDetails theRequestDetails, RestOperationTypeEnum theRestOperationType) { 252 if (myNarrowConditionalUrls) { 253 String conditionalUrl = theRequestDetails.getConditionalUrl(theRestOperationType); 254 if (isNotBlank(conditionalUrl)) { 255 String newConditionalUrl = narrowConditionalUrlForCompartmentOnly( 256 theRequestDetails, conditionalUrl, false, theRequestDetails.getResourceName()); 257 if (newConditionalUrl != null) { 258 String newCompleteUrl = theRequestDetails 259 .getCompleteUrl() 260 .substring( 261 0, 262 theRequestDetails.getCompleteUrl().indexOf('?') + 1) 263 + newConditionalUrl; 264 theRequestDetails.setCompleteUrl(newCompleteUrl); 265 } 266 } 267 } 268 } 269 270 /** 271 * Does not narrow codes 272 */ 273 @Nullable 274 private String narrowConditionalUrlForCompartmentOnly( 275 RequestDetails theRequestDetails, 276 @Nonnull String theConditionalUrl, 277 boolean theIncludeUpToQuestionMarkInResponse, 278 String theResourceName) { 279 AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails); 280 return narrowConditionalUrl( 281 theRequestDetails, 282 theConditionalUrl, 283 theIncludeUpToQuestionMarkInResponse, 284 theResourceName, 285 false, 286 authorizedList); 287 } 288 289 @Nullable 290 private String narrowConditionalUrl( 291 RequestDetails theRequestDetails, 292 @Nonnull String theConditionalUrl, 293 boolean theIncludeUpToQuestionMarkInResponse, 294 String theResourceName, 295 boolean theNarrowCodes, 296 AuthorizedList theAuthorizedList) { 297 if (theAuthorizedList == null) { 298 return null; 299 } 300 301 ListMultimap<String, String> parametersToAdd = 302 buildParameterListForAuthorizedCompartment(theRequestDetails, theResourceName, theAuthorizedList); 303 304 if (theNarrowCodes) { 305 ListMultimap<String, String> parametersToAddForCodes = 306 buildParameterListForAuthorizedCodes(theRequestDetails, theResourceName, theAuthorizedList); 307 if (parametersToAdd == null) { 308 parametersToAdd = parametersToAddForCodes; 309 } else if (parametersToAddForCodes != null) { 310 parametersToAdd.putAll(parametersToAddForCodes); 311 } 312 } 313 314 String newConditionalUrl = null; 315 if (parametersToAdd != null) { 316 317 String query = theConditionalUrl; 318 int qMarkIndex = theConditionalUrl.indexOf('?'); 319 if (qMarkIndex != -1) { 320 query = theConditionalUrl.substring(qMarkIndex + 1); 321 } 322 323 Map<String, String[]> inputParams = UrlUtil.parseQueryString(query); 324 Map<String, String[]> newParameters = applyCompartmentParameters(parametersToAdd, true, inputParams); 325 326 StringBuilder newUrl = new StringBuilder(); 327 if (theIncludeUpToQuestionMarkInResponse) { 328 newUrl.append(qMarkIndex != -1 ? theConditionalUrl.substring(0, qMarkIndex + 1) : "?"); 329 } 330 331 boolean first = true; 332 for (Map.Entry<String, String[]> nextEntry : newParameters.entrySet()) { 333 for (String nextValue : nextEntry.getValue()) { 334 if (isNotBlank(nextValue)) { 335 if (first) { 336 first = false; 337 } else { 338 newUrl.append("&"); 339 } 340 newUrl.append(UrlUtil.escapeUrlParam(nextEntry.getKey())); 341 newUrl.append("="); 342 newUrl.append(UrlUtil.escapeUrlParam(nextValue)); 343 } 344 } 345 } 346 347 newConditionalUrl = newUrl.toString(); 348 } 349 return newConditionalUrl; 350 } 351 352 private void narrowTypeSearch(RequestDetails theRequestDetails) { 353 354 // N.B do not add code above this for filtering, this should only ever occur on search. 355 if (shouldSkipNarrowing(theRequestDetails)) { 356 return; 357 } 358 359 AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails); 360 if (authorizedList == null) { 361 return; 362 } 363 364 String resourceName = theRequestDetails.getResourceName(); 365 366 // Narrow request URL for compartments 367 ListMultimap<String, String> parametersToAdd = 368 buildParameterListForAuthorizedCompartment(theRequestDetails, resourceName, authorizedList); 369 if (parametersToAdd != null) { 370 applyParametersToRequestDetails(theRequestDetails, parametersToAdd, true); 371 } 372 373 // Narrow request URL for codes - Add rules to request so that the SearchNarrowingConsentService can pick them 374 // up 375 ListMultimap<String, String> parameterToOrValues = 376 buildParameterListForAuthorizedCodes(theRequestDetails, resourceName, authorizedList); 377 if (parameterToOrValues != null) { 378 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, false); 379 } 380 } 381 382 @Nullable 383 private ListMultimap<String, String> buildParameterListForAuthorizedCodes( 384 RequestDetails theRequestDetails, String resourceName, AuthorizedList authorizedList) { 385 List<AllowedCodeInValueSet> postFilteringList = getPostFilteringList(theRequestDetails); 386 if (authorizedList.getAllowedCodeInValueSets() != null) { 387 postFilteringList.addAll(authorizedList.getAllowedCodeInValueSets()); 388 } 389 390 List<AllowedCodeInValueSet> allowedCodeInValueSet = authorizedList.getAllowedCodeInValueSets(); 391 ListMultimap<String, String> parameterToOrValues = null; 392 if (allowedCodeInValueSet != null) { 393 FhirContext context = theRequestDetails.getServer().getFhirContext(); 394 RuntimeResourceDefinition resourceDef = context.getResourceDefinition(resourceName); 395 parameterToOrValues = processAllowedCodes(resourceDef, allowedCodeInValueSet); 396 } 397 return parameterToOrValues; 398 } 399 400 @Nullable 401 private ListMultimap<String, String> buildParameterListForAuthorizedCompartment( 402 RequestDetails theRequestDetails, String theResourceName, @Nullable AuthorizedList theAuthorizedList) { 403 if (theAuthorizedList == null) { 404 return null; 405 } 406 407 FhirContext ctx = theRequestDetails.getServer().getFhirContext(); 408 RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theResourceName); 409 410 /* 411 * Create a map of search parameter values that need to be added to the 412 * given request 413 */ 414 Collection<String> compartments = theAuthorizedList.getAllowedCompartments(); 415 ListMultimap<String, String> parametersToAdd = null; 416 if (compartments != null) { 417 parametersToAdd = 418 processResourcesOrCompartments(theRequestDetails, resDef, compartments, true, theResourceName); 419 } 420 421 Collection<String> resources = theAuthorizedList.getAllowedInstances(); 422 if (resources != null) { 423 ListMultimap<String, String> parameterToOrValues = 424 processResourcesOrCompartments(theRequestDetails, resDef, resources, false, theResourceName); 425 if (parametersToAdd == null) { 426 parametersToAdd = parameterToOrValues; 427 } else if (parameterToOrValues != null) { 428 parametersToAdd.putAll(parameterToOrValues); 429 } 430 } 431 return parametersToAdd; 432 } 433 434 /** 435 * Skip unless it is a search request or an $everything operation 436 */ 437 private boolean shouldSkipNarrowing(RequestDetails theRequestDetails) { 438 return theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE 439 && !"$everything".equalsIgnoreCase(theRequestDetails.getOperation()); 440 } 441 442 private void applyParametersToRequestDetails( 443 RequestDetails theRequestDetails, 444 @Nullable ListMultimap<String, String> theParameterToOrValues, 445 boolean thePatientIdMode) { 446 Map<String, String[]> inputParameters = theRequestDetails.getParameters(); 447 if (theParameterToOrValues != null) { 448 Map<String, String[]> newParameters = 449 applyCompartmentParameters(theParameterToOrValues, thePatientIdMode, inputParameters); 450 theRequestDetails.setParameters(newParameters); 451 } 452 } 453 454 @Nullable 455 private ListMultimap<String, String> processResourcesOrCompartments( 456 RequestDetails theRequestDetails, 457 RuntimeResourceDefinition theResDef, 458 Collection<String> theResourcesOrCompartments, 459 boolean theAreCompartments, 460 String theResourceName) { 461 ListMultimap<String, String> retVal = null; 462 463 String lastCompartmentName = null; 464 String lastSearchParamName = null; 465 for (String nextCompartment : theResourcesOrCompartments) { 466 Validate.isTrue( 467 StringUtils.countMatches(nextCompartment, '/') == 1, 468 "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", 469 nextCompartment); 470 String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/')); 471 472 String searchParamName = null; 473 if (compartmentName.equalsIgnoreCase(lastCompartmentName)) { 474 475 // Avoid doing a lookup for the same thing repeatedly 476 searchParamName = lastSearchParamName; 477 478 } else { 479 480 if (compartmentName.equalsIgnoreCase(theResourceName)) { 481 482 searchParamName = "_id"; 483 484 } else if (theAreCompartments) { 485 486 searchParamName = 487 selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName); 488 } 489 490 lastCompartmentName = compartmentName; 491 lastSearchParamName = searchParamName; 492 } 493 494 if (searchParamName != null) { 495 if (retVal == null) { 496 retVal = MultimapBuilder.hashKeys().arrayListValues().build(); 497 } 498 retVal.put(searchParamName, nextCompartment); 499 } 500 } 501 502 return retVal; 503 } 504 505 @Nullable 506 private ListMultimap<String, String> processAllowedCodes( 507 RuntimeResourceDefinition theResDef, List<AllowedCodeInValueSet> theAllowedCodeInValueSet) { 508 ListMultimap<String, String> retVal = null; 509 510 for (AllowedCodeInValueSet next : theAllowedCodeInValueSet) { 511 String resourceName = next.getResourceName(); 512 String valueSetUrl = next.getValueSetUrl(); 513 514 ValidateUtil.isNotBlankOrThrowIllegalArgument( 515 resourceName, "Resource name supplied by SearchNarrowingInterceptor must not be null"); 516 ValidateUtil.isNotBlankOrThrowIllegalArgument( 517 valueSetUrl, "ValueSet URL supplied by SearchNarrowingInterceptor must not be null"); 518 519 if (!resourceName.equals(theResDef.getName())) { 520 continue; 521 } 522 523 if (shouldHandleThroughConsentService(valueSetUrl)) { 524 continue; 525 } 526 527 String paramName; 528 if (next.isNegate()) { 529 paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_NOT_IN; 530 } else { 531 paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_IN; 532 } 533 534 if (retVal == null) { 535 retVal = MultimapBuilder.hashKeys().arrayListValues().build(); 536 } 537 retVal.put(paramName, valueSetUrl); 538 } 539 540 return retVal; 541 } 542 543 /** 544 * For a given ValueSet URL, expand the valueset and check if the number of 545 * codes present is larger than the post filter threshold. 546 */ 547 private boolean shouldHandleThroughConsentService(String theValueSetUrl) { 548 if (myValidationSupport != null && myPostFilterLargeValueSetThreshold != -1) { 549 ValidationSupportContext ctx = new ValidationSupportContext(myValidationSupport); 550 ValueSetExpansionOptions options = new ValueSetExpansionOptions(); 551 options.setCount(myPostFilterLargeValueSetThreshold); 552 options.setIncludeHierarchy(false); 553 IValidationSupport.ValueSetExpansionOutcome outcome = 554 myValidationSupport.expandValueSet(ctx, options, theValueSetUrl); 555 if (outcome != null && outcome.getValueSet() != null) { 556 FhirTerser terser = myValidationSupport.getFhirContext().newTerser(); 557 List<IBase> contains = terser.getValues(outcome.getValueSet(), "ValueSet.expansion.contains"); 558 int codeCount = contains.size(); 559 return codeCount >= myPostFilterLargeValueSetThreshold; 560 } 561 } 562 return false; 563 } 564 565 private String selectBestSearchParameterForCompartment( 566 RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) { 567 String searchParamName = null; 568 569 Set<String> queryParameters = theRequestDetails.getParameters().keySet(); 570 571 List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName); 572 if (!searchParams.isEmpty()) { 573 574 // Resources like Observation have several fields that add the resource to 575 // the compartment. In the case of Observation, it's subject, patient and performer. 576 // For this kind of thing, we'll prefer the one that matches the compartment name. 577 Optional<RuntimeSearchParam> primarySearchParam = searchParams.stream() 578 .filter(t -> t.getName().equalsIgnoreCase(compartmentName)) 579 .findFirst(); 580 581 if (primarySearchParam.isPresent()) { 582 String primarySearchParamName = primarySearchParam.get().getName(); 583 // If the primary search parameter is actually in use in the query, use it. 584 if (queryParameters.contains(primarySearchParamName)) { 585 searchParamName = primarySearchParamName; 586 } else { 587 // If the primary search parameter itself isn't in use, check to see whether any of its synonyms 588 // are. 589 Optional<RuntimeSearchParam> synonymInUse = 590 findSynonyms(searchParams, primarySearchParam.get()).stream() 591 .filter(t -> queryParameters.contains(t.getName())) 592 .findFirst(); 593 if (synonymInUse.isPresent()) { 594 // if a synonym is in use, use it 595 searchParamName = synonymInUse.get().getName(); 596 } else { 597 // if not, i.e., the original query is not filtering on this field at all, use the primary 598 // search param 599 searchParamName = primarySearchParamName; 600 } 601 } 602 } else { 603 // Otherwise, fall back to whatever search parameter is available 604 searchParamName = searchParams.get(0).getName(); 605 } 606 } 607 return searchParamName; 608 } 609 610 private List<RuntimeSearchParam> findSynonyms( 611 List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) { 612 // We define two search parameters in a compartment as synonyms if they refer to the same field in the model, 613 // ignoring any qualifiers 614 615 String primaryBasePath = getBasePath(primarySearchParam); 616 617 return searchParams.stream() 618 .filter(t -> primaryBasePath.equals(getBasePath(t))) 619 .collect(Collectors.toList()); 620 } 621 622 private String getBasePath(RuntimeSearchParam searchParam) { 623 int qualifierIndex = searchParam.getPath().indexOf(".where"); 624 if (qualifierIndex == -1) { 625 return searchParam.getPath(); 626 } else { 627 return searchParam.getPath().substring(0, qualifierIndex); 628 } 629 } 630 631 @Nonnull 632 private static Map<String, String[]> applyCompartmentParameters( 633 @Nonnull ListMultimap<String, String> theParameterToOrValues, 634 boolean thePatientIdMode, 635 Map<String, String[]> theInputParameters) { 636 Map<String, String[]> newParameters = new HashMap<>(theInputParameters); 637 for (String nextParamName : theParameterToOrValues.keySet()) { 638 List<String> nextAllowedValues = theParameterToOrValues.get(nextParamName); 639 640 if (!newParameters.containsKey(nextParamName)) { 641 642 /* 643 * If we don't already have a parameter of the given type, add one 644 */ 645 String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues); 646 String[] paramValues = {nextValuesJoined}; 647 newParameters.put(nextParamName, paramValues); 648 649 } else { 650 651 /* 652 * If the client explicitly requested the given parameter already, we'll 653 * just update the request to have the intersection of the values that the client 654 * requested, and the values that the user is allowed to see 655 */ 656 String[] existingValues = newParameters.get(nextParamName); 657 658 if (thePatientIdMode) { 659 List<String> nextAllowedValueIds = nextAllowedValues.stream() 660 .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t) 661 .collect(Collectors.toList()); 662 boolean restrictedExistingList = false; 663 for (int i = 0; i < existingValues.length; i++) { 664 665 String nextExistingValue = existingValues[i]; 666 List<String> nextRequestedValues = 667 QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue); 668 List<String> nextPermittedValues = ListUtils.union( 669 ListUtils.intersection(nextRequestedValues, nextAllowedValues), 670 ListUtils.intersection(nextRequestedValues, nextAllowedValueIds)); 671 if (!nextPermittedValues.isEmpty()) { 672 restrictedExistingList = true; 673 existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues); 674 } 675 } 676 677 /* 678 * If none of the values that were requested by the client overlap at all 679 * with the values that the user is allowed to see, the client shouldn't 680 * get *any* results back. We return an error code indicating that the 681 * caller is forbidden from accessing the resources they requested. 682 */ 683 if (!restrictedExistingList) { 684 throw new ForbiddenOperationException(Msg.code(2026) + "Value not permitted for parameter " 685 + UrlUtil.escapeUrlParam(nextParamName)); 686 } 687 688 } else { 689 690 int existingValuesCount = existingValues.length; 691 String[] newValues = Arrays.copyOf(existingValues, existingValuesCount + nextAllowedValues.size()); 692 for (int i = 0; i < nextAllowedValues.size(); i++) { 693 newValues[existingValuesCount + i] = nextAllowedValues.get(i); 694 } 695 newParameters.put(nextParamName, newValues); 696 } 697 } 698 } 699 return newParameters; 700 } 701 702 static List<AllowedCodeInValueSet> getPostFilteringList(RequestDetails theRequestDetails) { 703 List<AllowedCodeInValueSet> retVal = getPostFilteringListOrNull(theRequestDetails); 704 if (retVal == null) { 705 retVal = new ArrayList<>(); 706 theRequestDetails.setAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME, retVal); 707 } 708 return retVal; 709 } 710 711 @SuppressWarnings("unchecked") 712 static List<AllowedCodeInValueSet> getPostFilteringListOrNull(RequestDetails theRequestDetails) { 713 return (List<AllowedCodeInValueSet>) theRequestDetails.getAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME); 714 } 715 716 private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> { 717 private final FhirContext myFhirContext; 718 private final ServletRequestDetails myRequestDetails; 719 private final AuthorizedList myAuthorizedList; 720 721 public BundleEntryUrlProcessor(FhirContext theFhirContext, ServletRequestDetails theRequestDetails) { 722 myFhirContext = theFhirContext; 723 myRequestDetails = theRequestDetails; 724 myAuthorizedList = buildAuthorizedList(theRequestDetails); 725 } 726 727 @SuppressWarnings("EnumSwitchStatementWhichMissesCases") 728 @Override 729 public void accept(ModifiableBundleEntry theModifiableBundleEntry) { 730 if (myAuthorizedList == null) { 731 return; 732 } 733 734 RequestTypeEnum method = theModifiableBundleEntry.getRequestMethod(); 735 String requestUrl = theModifiableBundleEntry.getRequestUrl(); 736 if (method != null && isNotBlank(requestUrl)) { 737 738 String resourceType = UrlUtil.parseUrl(requestUrl).getResourceType(); 739 740 switch (method) { 741 case GET: { 742 String existingRequestUrl = theModifiableBundleEntry.getRequestUrl(); 743 String newConditionalUrl = narrowConditionalUrl( 744 myRequestDetails, existingRequestUrl, false, resourceType, true, myAuthorizedList); 745 if (isNotBlank(newConditionalUrl)) { 746 newConditionalUrl = resourceType + "?" + newConditionalUrl; 747 theModifiableBundleEntry.setRequestUrl(myFhirContext, newConditionalUrl); 748 } 749 break; 750 } 751 case POST: { 752 if (myNarrowConditionalUrls) { 753 String existingConditionalUrl = theModifiableBundleEntry.getConditionalUrl(); 754 if (isNotBlank(existingConditionalUrl)) { 755 String newConditionalUrl = narrowConditionalUrl( 756 myRequestDetails, 757 existingConditionalUrl, 758 true, 759 resourceType, 760 false, 761 myAuthorizedList); 762 if (isNotBlank(newConditionalUrl)) { 763 theModifiableBundleEntry.setRequestIfNoneExist(myFhirContext, newConditionalUrl); 764 } 765 } 766 } 767 break; 768 } 769 case PUT: 770 case DELETE: 771 case PATCH: { 772 if (myNarrowConditionalUrls) { 773 String existingConditionalUrl = theModifiableBundleEntry.getConditionalUrl(); 774 if (isNotBlank(existingConditionalUrl)) { 775 String newConditionalUrl = narrowConditionalUrl( 776 myRequestDetails, 777 existingConditionalUrl, 778 true, 779 resourceType, 780 false, 781 myAuthorizedList); 782 if (isNotBlank(newConditionalUrl)) { 783 theModifiableBundleEntry.setRequestUrl(myFhirContext, newConditionalUrl); 784 } 785 } 786 } 787 break; 788 } 789 } 790 } 791 } 792 } 793}