001package ca.uhn.fhir.rest.server.interceptor.auth; 002 003/*- 004 * #%L 005 * HAPI FHIR - Server Framework 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.context.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 AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails); 150 if (authorizedList == null) { 151 return true; 152 } 153 154 // Add rules to request so that the SearchNarrowingConsentService can pick them up 155 List<AllowedCodeInValueSet> postFilteringList = getPostFilteringList(theRequestDetails); 156 if (authorizedList.getAllowedCodeInValueSets() != null) { 157 postFilteringList.addAll(authorizedList.getAllowedCodeInValueSets()); 158 } 159 160 if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE) { 161 return true; 162 } 163 164 FhirContext ctx = theRequestDetails.getServer().getFhirContext(); 165 RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName()); 166 /* 167 * Create a map of search parameter values that need to be added to the 168 * given request 169 */ 170 Collection<String> compartments = authorizedList.getAllowedCompartments(); 171 if (compartments != null) { 172 Map<String, List<String>> parameterToOrValues = processResourcesOrCompartments(theRequestDetails, resDef, compartments, true); 173 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true); 174 } 175 Collection<String> resources = authorizedList.getAllowedInstances(); 176 if (resources != null) { 177 Map<String, List<String>> parameterToOrValues = processResourcesOrCompartments(theRequestDetails, resDef, resources, false); 178 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true); 179 } 180 List<AllowedCodeInValueSet> allowedCodeInValueSet = authorizedList.getAllowedCodeInValueSets(); 181 if (allowedCodeInValueSet != null) { 182 Map<String, List<String>> parameterToOrValues = processAllowedCodes(resDef, allowedCodeInValueSet); 183 applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, false); 184 } 185 186 return true; 187 } 188 189 @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) 190 public void hookIncomingRequestPreHandled(ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException { 191 if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.TRANSACTION) { 192 return; 193 } 194 195 IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource(); 196 FhirContext ctx = theRequestDetails.getFhirContext(); 197 BundleEntryUrlProcessor processor = new BundleEntryUrlProcessor(ctx, theRequestDetails, theRequest, theResponse); 198 BundleUtil.processEntries(ctx, bundle, processor); 199 } 200 201 private void applyParametersToRequestDetails(RequestDetails theRequestDetails, @Nullable Map<String, List<String>> theParameterToOrValues, boolean thePatientIdMode) { 202 if (theParameterToOrValues != null) { 203 Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters()); 204 for (Map.Entry<String, List<String>> nextEntry : theParameterToOrValues.entrySet()) { 205 String nextParamName = nextEntry.getKey(); 206 List<String> nextAllowedValues = nextEntry.getValue(); 207 208 if (!newParameters.containsKey(nextParamName)) { 209 210 /* 211 * If we don't already have a parameter of the given type, add one 212 */ 213 String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues); 214 String[] paramValues = {nextValuesJoined}; 215 newParameters.put(nextParamName, paramValues); 216 217 } else { 218 219 /* 220 * If the client explicitly requested the given parameter already, we'll 221 * just update the request to have the intersection of the values that the client 222 * requested, and the values that the user is allowed to see 223 */ 224 String[] existingValues = newParameters.get(nextParamName); 225 226 if (thePatientIdMode) { 227 List<String> nextAllowedValueIds = nextAllowedValues 228 .stream() 229 .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t) 230 .collect(Collectors.toList()); 231 boolean restrictedExistingList = false; 232 for (int i = 0; i < existingValues.length; i++) { 233 234 String nextExistingValue = existingValues[i]; 235 List<String> nextRequestedValues = QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue); 236 List<String> nextPermittedValues = ListUtils.union( 237 ListUtils.intersection(nextRequestedValues, nextAllowedValues), 238 ListUtils.intersection(nextRequestedValues, nextAllowedValueIds) 239 ); 240 if (nextPermittedValues.size() > 0) { 241 restrictedExistingList = true; 242 existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues); 243 } 244 245 } 246 247 /* 248 * If none of the values that were requested by the client overlap at all 249 * with the values that the user is allowed to see, the client shouldn't 250 * get *any* results back. We return an error code indicating that the 251 * caller is forbidden from accessing the resources they requested. 252 */ 253 if (!restrictedExistingList) { 254 throw new ForbiddenOperationException(Msg.code(2026) + "Value not permitted for parameter " + UrlUtil.escapeUrlParam(nextParamName)); 255 } 256 257 } else { 258 259 int existingValuesCount = existingValues.length; 260 String[] newValues = Arrays.copyOf(existingValues, existingValuesCount + nextAllowedValues.size()); 261 for (int i = 0; i < nextAllowedValues.size(); i++) { 262 newValues[existingValuesCount + i] = nextAllowedValues.get(i); 263 } 264 newParameters.put(nextParamName, newValues); 265 266 } 267 268 } 269 270 } 271 theRequestDetails.setParameters(newParameters); 272 } 273 } 274 275 @Nullable 276 private Map<String, List<String>> processResourcesOrCompartments(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, Collection<String> theResourcesOrCompartments, boolean theAreCompartments) { 277 Map<String, List<String>> retVal = null; 278 279 String lastCompartmentName = null; 280 String lastSearchParamName = null; 281 for (String nextCompartment : theResourcesOrCompartments) { 282 Validate.isTrue(StringUtils.countMatches(nextCompartment, '/') == 1, "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", nextCompartment); 283 String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/')); 284 285 String searchParamName = null; 286 if (compartmentName.equalsIgnoreCase(lastCompartmentName)) { 287 288 // Avoid doing a lookup for the same thing repeatedly 289 searchParamName = lastSearchParamName; 290 291 } else { 292 293 if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) { 294 295 searchParamName = "_id"; 296 297 } else if (theAreCompartments) { 298 299 searchParamName = selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName); 300 } 301 302 lastCompartmentName = compartmentName; 303 lastSearchParamName = searchParamName; 304 305 } 306 307 if (searchParamName != null) { 308 if (retVal == null) { 309 retVal = new HashMap<>(); 310 } 311 List<String> orValues = retVal.computeIfAbsent(searchParamName, t -> new ArrayList<>()); 312 orValues.add(nextCompartment); 313 } 314 } 315 316 return retVal; 317 } 318 319 @Nullable 320 private Map<String, List<String>> processAllowedCodes(RuntimeResourceDefinition theResDef, List<AllowedCodeInValueSet> theAllowedCodeInValueSet) { 321 Map<String, List<String>> retVal = null; 322 323 for (AllowedCodeInValueSet next : theAllowedCodeInValueSet) { 324 String resourceName = next.getResourceName(); 325 String valueSetUrl = next.getValueSetUrl(); 326 327 ValidateUtil.isNotBlankOrThrowIllegalArgument(resourceName, "Resource name supplied by SearchNarrowingInterceptor must not be null"); 328 ValidateUtil.isNotBlankOrThrowIllegalArgument(valueSetUrl, "ValueSet URL supplied by SearchNarrowingInterceptor must not be null"); 329 330 if (!resourceName.equals(theResDef.getName())) { 331 continue; 332 } 333 334 if (shouldHandleThroughConsentService(valueSetUrl)) { 335 continue; 336 } 337 338 String paramName; 339 if (next.isNegate()) { 340 paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_NOT_IN; 341 } else { 342 paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_IN; 343 } 344 345 if (retVal == null) { 346 retVal = new HashMap<>(); 347 } 348 retVal.computeIfAbsent(paramName, k -> new ArrayList<>()).add(valueSetUrl); 349 } 350 351 return retVal; 352 } 353 354 /** 355 * For a given ValueSet URL, expand the valueset and check if the number of 356 * codes present is larger than the post filter threshold. 357 */ 358 private boolean shouldHandleThroughConsentService(String theValueSetUrl) { 359 if (myValidationSupport != null && myPostFilterLargeValueSetThreshold != -1) { 360 ValidationSupportContext ctx = new ValidationSupportContext(myValidationSupport); 361 ValueSetExpansionOptions options = new ValueSetExpansionOptions(); 362 options.setCount(myPostFilterLargeValueSetThreshold); 363 options.setIncludeHierarchy(false); 364 IValidationSupport.ValueSetExpansionOutcome outcome = myValidationSupport.expandValueSet(ctx, options, theValueSetUrl); 365 if (outcome != null && outcome.getValueSet() != null) { 366 FhirTerser terser = myValidationSupport.getFhirContext().newTerser(); 367 List<IBase> contains = terser.getValues(outcome.getValueSet(), "ValueSet.expansion.contains"); 368 int codeCount = contains.size(); 369 return codeCount >= myPostFilterLargeValueSetThreshold; 370 } 371 } 372 return false; 373 } 374 375 376 private String selectBestSearchParameterForCompartment(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) { 377 String searchParamName = null; 378 379 Set<String> queryParameters = theRequestDetails.getParameters().keySet(); 380 381 List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName); 382 if (searchParams.size() > 0) { 383 384 // Resources like Observation have several fields that add the resource to 385 // the compartment. In the case of Observation, it's subject, patient and performer. 386 // For this kind of thing, we'll prefer the one that matches the compartment name. 387 Optional<RuntimeSearchParam> primarySearchParam = 388 searchParams 389 .stream() 390 .filter(t -> t.getName().equalsIgnoreCase(compartmentName)) 391 .findFirst(); 392 393 if (primarySearchParam.isPresent()) { 394 String primarySearchParamName = primarySearchParam.get().getName(); 395 // If the primary search parameter is actually in use in the query, use it. 396 if (queryParameters.contains(primarySearchParamName)) { 397 searchParamName = primarySearchParamName; 398 } else { 399 // If the primary search parameter itself isn't in use, check to see whether any of its synonyms are. 400 Optional<RuntimeSearchParam> synonymInUse = findSynonyms(searchParams, primarySearchParam.get()) 401 .stream() 402 .filter(t -> queryParameters.contains(t.getName())) 403 .findFirst(); 404 if (synonymInUse.isPresent()) { 405 // if a synonym is in use, use it 406 searchParamName = synonymInUse.get().getName(); 407 } else { 408 // if not, i.e., the original query is not filtering on this field at all, use the primary search param 409 searchParamName = primarySearchParamName; 410 } 411 } 412 } else { 413 // Otherwise, fall back to whatever search parameter is available 414 searchParamName = searchParams.get(0).getName(); 415 } 416 417 } 418 return searchParamName; 419 } 420 421 private List<RuntimeSearchParam> findSynonyms(List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) { 422 // We define two search parameters in a compartment as synonyms if they refer to the same field in the model, ignoring any qualifiers 423 424 String primaryBasePath = getBasePath(primarySearchParam); 425 426 return searchParams 427 .stream() 428 .filter(t -> primaryBasePath.equals(getBasePath(t))) 429 .collect(Collectors.toList()); 430 } 431 432 private String getBasePath(RuntimeSearchParam searchParam) { 433 int qualifierIndex = searchParam.getPath().indexOf(".where"); 434 if (qualifierIndex == -1) { 435 return searchParam.getPath(); 436 } else { 437 return searchParam.getPath().substring(0, qualifierIndex); 438 } 439 } 440 441 private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> { 442 private final FhirContext myFhirContext; 443 private final ServletRequestDetails myRequestDetails; 444 private final HttpServletRequest myRequest; 445 private final HttpServletResponse myResponse; 446 447 public BundleEntryUrlProcessor(FhirContext theFhirContext, ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) { 448 myFhirContext = theFhirContext; 449 myRequestDetails = theRequestDetails; 450 myRequest = theRequest; 451 myResponse = theResponse; 452 } 453 454 @Override 455 public void accept(ModifiableBundleEntry theModifiableBundleEntry) { 456 ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create(); 457 458 String url = theModifiableBundleEntry.getRequestUrl(); 459 460 ServletSubRequestDetails subServletRequestDetails = ServletRequestUtil.getServletSubRequestDetails(myRequestDetails, url, paramValues); 461 BaseMethodBinding<?> method = subServletRequestDetails.getServer().determineResourceMethod(subServletRequestDetails, url); 462 RestOperationTypeEnum restOperationType = method.getRestOperationType(); 463 subServletRequestDetails.setRestOperationType(restOperationType); 464 465 hookIncomingRequestPostProcessed(subServletRequestDetails, myRequest, myResponse); 466 467 theModifiableBundleEntry.setRequestUrl(myFhirContext, ServletRequestUtil.extractUrl(subServletRequestDetails)); 468 } 469 } 470 471 472 static List<AllowedCodeInValueSet> getPostFilteringList(RequestDetails theRequestDetails) { 473 List<AllowedCodeInValueSet> retVal = getPostFilteringListOrNull(theRequestDetails); 474 if (retVal == null) { 475 retVal = new ArrayList<>(); 476 theRequestDetails.setAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME, retVal); 477 } 478 return retVal; 479 } 480 481 @SuppressWarnings("unchecked") 482 static List<AllowedCodeInValueSet> getPostFilteringListOrNull(RequestDetails theRequestDetails) { 483 return (List<AllowedCodeInValueSet>) theRequestDetails.getAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME); 484 } 485 486 487}