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.RestOperationTypeEnum; 034import ca.uhn.fhir.rest.api.server.RequestDetails; 035import ca.uhn.fhir.rest.param.ParameterUtil; 036import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; 037import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; 038import ca.uhn.fhir.rest.server.method.BaseMethodBinding; 039import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 040import ca.uhn.fhir.rest.server.servlet.ServletSubRequestDetails; 041import ca.uhn.fhir.rest.server.util.ServletRequestUtil; 042import ca.uhn.fhir.util.BundleUtil; 043import ca.uhn.fhir.util.FhirTerser; 044import ca.uhn.fhir.util.UrlUtil; 045import ca.uhn.fhir.util.ValidateUtil; 046import ca.uhn.fhir.util.bundle.ModifiableBundleEntry; 047import com.google.common.collect.ArrayListMultimap; 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 068/** 069 * This interceptor can be used to automatically narrow the scope of searches in order to 070 * automatically restrict the searches to specific compartments. 071 * <p> 072 * For example, this interceptor 073 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data 074 * in the <code>Patient/123</code> compartment). In this case, a user performing a search 075 * for<br/> 076 * <code>http://baseurl/Observation?category=laboratory</code><br/> 077 * would receive results as though they had requested<br/> 078 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code> 079 * </p> 080 * <p> 081 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor} 082 * if you are restricting results because of a security restriction. This interceptor is not 083 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the 084 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to 085 * help users simplify their queries while not receiving security errors for to trying to access 086 * data they do not have access to see. 087 * </p> 088 * 089 * @see AuthorizationInterceptor 090 */ 091public class SearchNarrowingInterceptor { 092 093 public static final String POST_FILTERING_LIST_ATTRIBUTE_NAME = 094 SearchNarrowingInterceptor.class.getName() + "_POST_FILTERING_LIST"; 095 private IValidationSupport myValidationSupport; 096 private int myPostFilterLargeValueSetThreshold = 500; 097 098 /** 099 * Supplies a threshold over which any ValueSet-based rules will be applied by 100 * 101 * 102 * <p> 103 * Note that this setting will have no effect if {@link #setValidationSupport(IValidationSupport)} 104 * has not also been called in order to supply a validation support module for 105 * testing ValueSet membership. 106 * </p> 107 * 108 * @param thePostFilterLargeValueSetThreshold The threshold 109 * @see #setValidationSupport(IValidationSupport) 110 */ 111 public void setPostFilterLargeValueSetThreshold(int thePostFilterLargeValueSetThreshold) { 112 Validate.isTrue( 113 thePostFilterLargeValueSetThreshold > 0, 114 "thePostFilterLargeValueSetThreshold must be a positive integer"); 115 myPostFilterLargeValueSetThreshold = thePostFilterLargeValueSetThreshold; 116 } 117 118 /** 119 * Supplies a validation support module that will be used to apply the 120 * 121 * @see #setPostFilterLargeValueSetThreshold(int) 122 * @since 6.0.0 123 */ 124 public SearchNarrowingInterceptor setValidationSupport(IValidationSupport theValidationSupport) { 125 myValidationSupport = theValidationSupport; 126 return this; 127 } 128 129 /** 130 * Subclasses should override this method to supply the set of compartments that 131 * the user making the request should actually have access to. 132 * <p> 133 * Typically this is done by examining <code>theRequestDetails</code> to find 134 * out who the current user is and then building a list of Strings. 135 * </p> 136 * 137 * @param theRequestDetails The individual request currently being applied 138 * @return The list of allowed compartments and instances that should be used 139 * for search narrowing. If this method returns <code>null</code>, no narrowing will 140 * be performed 141 */ 142 protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) { 143 return null; 144 } 145 146 @Hook(Pointcut.SERVER_INCOMING_REQUEST_POST_PROCESSED) 147 public boolean hookIncomingRequestPostProcessed( 148 RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) 149 throws AuthenticationException { 150 // We don't support this operation type yet 151 Validate.isTrue(theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_SYSTEM); 152 153 // N.B do not add code above this for filtering, this should only ever occur on search. 154 if (shouldSkipNarrowing(theRequestDetails)) { 155 return true; 156 } 157 158 AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails); 159 if (authorizedList == null) { 160 return true; 161 } 162 163 // Add rules to request so that the SearchNarrowingConsentService can pick them up 164 List<AllowedCodeInValueSet> postFilteringList = getPostFilteringList(theRequestDetails); 165 if (authorizedList.getAllowedCodeInValueSets() != null) { 166 postFilteringList.addAll(authorizedList.getAllowedCodeInValueSets()); 167 } 168 169 FhirContext ctx = theRequestDetails.getServer().getFhirContext(); 170 RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName()); 171 /* 172 * Create a map of search parameter values that need to be added to the 173 * given request 174 */ 175 Collection<String> compartments = authorizedList.getAllowedCompartments(); 176 if (compartments != null) { 177 Map<String, List<String>> parameterToOrValues = 178 processResourcesOrCompartments(theRequestDetails, resDef, compartments, true); 179 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true); 180 } 181 Collection<String> resources = authorizedList.getAllowedInstances(); 182 if (resources != null) { 183 Map<String, List<String>> parameterToOrValues = 184 processResourcesOrCompartments(theRequestDetails, resDef, resources, false); 185 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true); 186 } 187 List<AllowedCodeInValueSet> allowedCodeInValueSet = authorizedList.getAllowedCodeInValueSets(); 188 if (allowedCodeInValueSet != null) { 189 Map<String, List<String>> parameterToOrValues = processAllowedCodes(resDef, allowedCodeInValueSet); 190 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, false); 191 } 192 193 return true; 194 } 195 196 /** 197 * Skip unless it is a search request or an $everything operation 198 */ 199 private boolean shouldSkipNarrowing(RequestDetails theRequestDetails) { 200 return theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE 201 && !"$everything".equalsIgnoreCase(theRequestDetails.getOperation()); 202 } 203 204 @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) 205 public void hookIncomingRequestPreHandled( 206 ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) 207 throws AuthenticationException { 208 if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.TRANSACTION) { 209 return; 210 } 211 212 IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource(); 213 FhirContext ctx = theRequestDetails.getFhirContext(); 214 BundleEntryUrlProcessor processor = 215 new BundleEntryUrlProcessor(ctx, theRequestDetails, theRequest, theResponse); 216 BundleUtil.processEntries(ctx, bundle, processor); 217 } 218 219 private void applyParametersToRequestDetails( 220 RequestDetails theRequestDetails, 221 @Nullable Map<String, List<String>> theParameterToOrValues, 222 boolean thePatientIdMode) { 223 if (theParameterToOrValues != null) { 224 Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters()); 225 for (Map.Entry<String, List<String>> nextEntry : theParameterToOrValues.entrySet()) { 226 String nextParamName = nextEntry.getKey(); 227 List<String> nextAllowedValues = nextEntry.getValue(); 228 229 if (!newParameters.containsKey(nextParamName)) { 230 231 /* 232 * If we don't already have a parameter of the given type, add one 233 */ 234 String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues); 235 String[] paramValues = {nextValuesJoined}; 236 newParameters.put(nextParamName, paramValues); 237 238 } else { 239 240 /* 241 * If the client explicitly requested the given parameter already, we'll 242 * just update the request to have the intersection of the values that the client 243 * requested, and the values that the user is allowed to see 244 */ 245 String[] existingValues = newParameters.get(nextParamName); 246 247 if (thePatientIdMode) { 248 List<String> nextAllowedValueIds = nextAllowedValues.stream() 249 .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t) 250 .collect(Collectors.toList()); 251 boolean restrictedExistingList = false; 252 for (int i = 0; i < existingValues.length; i++) { 253 254 String nextExistingValue = existingValues[i]; 255 List<String> nextRequestedValues = 256 QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue); 257 List<String> nextPermittedValues = ListUtils.union( 258 ListUtils.intersection(nextRequestedValues, nextAllowedValues), 259 ListUtils.intersection(nextRequestedValues, nextAllowedValueIds)); 260 if (nextPermittedValues.size() > 0) { 261 restrictedExistingList = true; 262 existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues); 263 } 264 } 265 266 /* 267 * If none of the values that were requested by the client overlap at all 268 * with the values that the user is allowed to see, the client shouldn't 269 * get *any* results back. We return an error code indicating that the 270 * caller is forbidden from accessing the resources they requested. 271 */ 272 if (!restrictedExistingList) { 273 throw new ForbiddenOperationException(Msg.code(2026) + "Value not permitted for parameter " 274 + UrlUtil.escapeUrlParam(nextParamName)); 275 } 276 277 } else { 278 279 int existingValuesCount = existingValues.length; 280 String[] newValues = 281 Arrays.copyOf(existingValues, existingValuesCount + nextAllowedValues.size()); 282 for (int i = 0; i < nextAllowedValues.size(); i++) { 283 newValues[existingValuesCount + i] = nextAllowedValues.get(i); 284 } 285 newParameters.put(nextParamName, newValues); 286 } 287 } 288 } 289 theRequestDetails.setParameters(newParameters); 290 } 291 } 292 293 @Nullable 294 private Map<String, List<String>> processResourcesOrCompartments( 295 RequestDetails theRequestDetails, 296 RuntimeResourceDefinition theResDef, 297 Collection<String> theResourcesOrCompartments, 298 boolean theAreCompartments) { 299 Map<String, List<String>> retVal = null; 300 301 String lastCompartmentName = null; 302 String lastSearchParamName = null; 303 for (String nextCompartment : theResourcesOrCompartments) { 304 Validate.isTrue( 305 StringUtils.countMatches(nextCompartment, '/') == 1, 306 "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", 307 nextCompartment); 308 String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/')); 309 310 String searchParamName = null; 311 if (compartmentName.equalsIgnoreCase(lastCompartmentName)) { 312 313 // Avoid doing a lookup for the same thing repeatedly 314 searchParamName = lastSearchParamName; 315 316 } else { 317 318 if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) { 319 320 searchParamName = "_id"; 321 322 } else if (theAreCompartments) { 323 324 searchParamName = 325 selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName); 326 } 327 328 lastCompartmentName = compartmentName; 329 lastSearchParamName = searchParamName; 330 } 331 332 if (searchParamName != null) { 333 if (retVal == null) { 334 retVal = new HashMap<>(); 335 } 336 List<String> orValues = retVal.computeIfAbsent(searchParamName, t -> new ArrayList<>()); 337 orValues.add(nextCompartment); 338 } 339 } 340 341 return retVal; 342 } 343 344 @Nullable 345 private Map<String, List<String>> processAllowedCodes( 346 RuntimeResourceDefinition theResDef, List<AllowedCodeInValueSet> theAllowedCodeInValueSet) { 347 Map<String, List<String>> retVal = null; 348 349 for (AllowedCodeInValueSet next : theAllowedCodeInValueSet) { 350 String resourceName = next.getResourceName(); 351 String valueSetUrl = next.getValueSetUrl(); 352 353 ValidateUtil.isNotBlankOrThrowIllegalArgument( 354 resourceName, "Resource name supplied by SearchNarrowingInterceptor must not be null"); 355 ValidateUtil.isNotBlankOrThrowIllegalArgument( 356 valueSetUrl, "ValueSet URL supplied by SearchNarrowingInterceptor must not be null"); 357 358 if (!resourceName.equals(theResDef.getName())) { 359 continue; 360 } 361 362 if (shouldHandleThroughConsentService(valueSetUrl)) { 363 continue; 364 } 365 366 String paramName; 367 if (next.isNegate()) { 368 paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_NOT_IN; 369 } else { 370 paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_IN; 371 } 372 373 if (retVal == null) { 374 retVal = new HashMap<>(); 375 } 376 retVal.computeIfAbsent(paramName, k -> new ArrayList<>()).add(valueSetUrl); 377 } 378 379 return retVal; 380 } 381 382 /** 383 * For a given ValueSet URL, expand the valueset and check if the number of 384 * codes present is larger than the post filter threshold. 385 */ 386 private boolean shouldHandleThroughConsentService(String theValueSetUrl) { 387 if (myValidationSupport != null && myPostFilterLargeValueSetThreshold != -1) { 388 ValidationSupportContext ctx = new ValidationSupportContext(myValidationSupport); 389 ValueSetExpansionOptions options = new ValueSetExpansionOptions(); 390 options.setCount(myPostFilterLargeValueSetThreshold); 391 options.setIncludeHierarchy(false); 392 IValidationSupport.ValueSetExpansionOutcome outcome = 393 myValidationSupport.expandValueSet(ctx, options, theValueSetUrl); 394 if (outcome != null && outcome.getValueSet() != null) { 395 FhirTerser terser = myValidationSupport.getFhirContext().newTerser(); 396 List<IBase> contains = terser.getValues(outcome.getValueSet(), "ValueSet.expansion.contains"); 397 int codeCount = contains.size(); 398 return codeCount >= myPostFilterLargeValueSetThreshold; 399 } 400 } 401 return false; 402 } 403 404 private String selectBestSearchParameterForCompartment( 405 RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) { 406 String searchParamName = null; 407 408 Set<String> queryParameters = theRequestDetails.getParameters().keySet(); 409 410 List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName); 411 if (searchParams.size() > 0) { 412 413 // Resources like Observation have several fields that add the resource to 414 // the compartment. In the case of Observation, it's subject, patient and performer. 415 // For this kind of thing, we'll prefer the one that matches the compartment name. 416 Optional<RuntimeSearchParam> primarySearchParam = searchParams.stream() 417 .filter(t -> t.getName().equalsIgnoreCase(compartmentName)) 418 .findFirst(); 419 420 if (primarySearchParam.isPresent()) { 421 String primarySearchParamName = primarySearchParam.get().getName(); 422 // If the primary search parameter is actually in use in the query, use it. 423 if (queryParameters.contains(primarySearchParamName)) { 424 searchParamName = primarySearchParamName; 425 } else { 426 // If the primary search parameter itself isn't in use, check to see whether any of its synonyms 427 // are. 428 Optional<RuntimeSearchParam> synonymInUse = 429 findSynonyms(searchParams, primarySearchParam.get()).stream() 430 .filter(t -> queryParameters.contains(t.getName())) 431 .findFirst(); 432 if (synonymInUse.isPresent()) { 433 // if a synonym is in use, use it 434 searchParamName = synonymInUse.get().getName(); 435 } else { 436 // if not, i.e., the original query is not filtering on this field at all, use the primary 437 // search param 438 searchParamName = primarySearchParamName; 439 } 440 } 441 } else { 442 // Otherwise, fall back to whatever search parameter is available 443 searchParamName = searchParams.get(0).getName(); 444 } 445 } 446 return searchParamName; 447 } 448 449 private List<RuntimeSearchParam> findSynonyms( 450 List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) { 451 // We define two search parameters in a compartment as synonyms if they refer to the same field in the model, 452 // ignoring any qualifiers 453 454 String primaryBasePath = getBasePath(primarySearchParam); 455 456 return searchParams.stream() 457 .filter(t -> primaryBasePath.equals(getBasePath(t))) 458 .collect(Collectors.toList()); 459 } 460 461 private String getBasePath(RuntimeSearchParam searchParam) { 462 int qualifierIndex = searchParam.getPath().indexOf(".where"); 463 if (qualifierIndex == -1) { 464 return searchParam.getPath(); 465 } else { 466 return searchParam.getPath().substring(0, qualifierIndex); 467 } 468 } 469 470 private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> { 471 private final FhirContext myFhirContext; 472 private final ServletRequestDetails myRequestDetails; 473 private final HttpServletRequest myRequest; 474 private final HttpServletResponse myResponse; 475 476 public BundleEntryUrlProcessor( 477 FhirContext theFhirContext, 478 ServletRequestDetails theRequestDetails, 479 HttpServletRequest theRequest, 480 HttpServletResponse theResponse) { 481 myFhirContext = theFhirContext; 482 myRequestDetails = theRequestDetails; 483 myRequest = theRequest; 484 myResponse = theResponse; 485 } 486 487 @Override 488 public void accept(ModifiableBundleEntry theModifiableBundleEntry) { 489 ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create(); 490 491 String url = theModifiableBundleEntry.getRequestUrl(); 492 493 ServletSubRequestDetails subServletRequestDetails = 494 ServletRequestUtil.getServletSubRequestDetails(myRequestDetails, url, paramValues); 495 BaseMethodBinding method = 496 subServletRequestDetails.getServer().determineResourceMethod(subServletRequestDetails, url); 497 RestOperationTypeEnum restOperationType = method.getRestOperationType(); 498 subServletRequestDetails.setRestOperationType(restOperationType); 499 500 hookIncomingRequestPostProcessed(subServletRequestDetails, myRequest, myResponse); 501 502 theModifiableBundleEntry.setRequestUrl( 503 myFhirContext, ServletRequestUtil.extractUrl(subServletRequestDetails)); 504 } 505 } 506 507 static List<AllowedCodeInValueSet> getPostFilteringList(RequestDetails theRequestDetails) { 508 List<AllowedCodeInValueSet> retVal = getPostFilteringListOrNull(theRequestDetails); 509 if (retVal == null) { 510 retVal = new ArrayList<>(); 511 theRequestDetails.setAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME, retVal); 512 } 513 return retVal; 514 } 515 516 @SuppressWarnings("unchecked") 517 static List<AllowedCodeInValueSet> getPostFilteringListOrNull(RequestDetails theRequestDetails) { 518 return (List<AllowedCodeInValueSet>) theRequestDetails.getAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME); 519 } 520}