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