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.interceptor.api.Hook; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.rest.api.Constants; 029import ca.uhn.fhir.rest.api.QualifiedParamList; 030import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 031import ca.uhn.fhir.rest.api.server.RequestDetails; 032import ca.uhn.fhir.rest.param.ParameterUtil; 033import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; 034import ca.uhn.fhir.rest.server.method.BaseMethodBinding; 035import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 036import ca.uhn.fhir.rest.server.servlet.ServletSubRequestDetails; 037import ca.uhn.fhir.rest.server.util.ServletRequestUtil; 038import ca.uhn.fhir.util.BundleUtil; 039import ca.uhn.fhir.util.bundle.ModifiableBundleEntry; 040import com.google.common.collect.ArrayListMultimap; 041import org.apache.commons.collections4.ListUtils; 042import org.apache.commons.lang3.StringUtils; 043import org.apache.commons.lang3.Validate; 044import org.hl7.fhir.instance.model.api.IBaseBundle; 045import org.slf4j.Logger; 046import org.slf4j.LoggerFactory; 047 048import javax.servlet.http.HttpServletRequest; 049import javax.servlet.http.HttpServletResponse; 050import java.util.*; 051import java.util.function.Consumer; 052import java.util.stream.Collectors; 053 054/** 055 * This interceptor can be used to automatically narrow the scope of searches in order to 056 * automatically restrict the searches to specific compartments. 057 * <p> 058 * For example, this interceptor 059 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data 060 * in the <code>Patient/123</code> compartment). In this case, a user performing a search 061 * for<br/> 062 * <code>http://baseurl/Observation?category=laboratory</code><br/> 063 * would receive results as though they had requested<br/> 064 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code> 065 * </p> 066 * <p> 067 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor} 068 * if you are restricting results because of a security restriction. This interceptor is not 069 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the 070 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to 071 * help users simplify their queries while not receiving security errors for to trying to access 072 * data they do not have access to see. 073 * </p> 074 * 075 * @see AuthorizationInterceptor 076 */ 077public class SearchNarrowingInterceptor { 078 private static final Logger ourLog = LoggerFactory.getLogger(SearchNarrowingInterceptor.class); 079 080 081 /** 082 * Subclasses should override this method to supply the set of compartments that 083 * the user making the request should actually have access to. 084 * <p> 085 * Typically this is done by examining <code>theRequestDetails</code> to find 086 * out who the current user is and then building a list of Strings. 087 * </p> 088 * 089 * @param theRequestDetails The individual request currently being applied 090 * @return The list of allowed compartments and instances that should be used 091 * for search narrowing. If this method returns <code>null</code>, no narrowing will 092 * be performed 093 */ 094 protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) { 095 return null; 096 } 097 098 @Hook(Pointcut.SERVER_INCOMING_REQUEST_POST_PROCESSED) 099 public boolean incomingRequestPostProcessed(RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException { 100 // We don't support this operation type yet 101 Validate.isTrue(theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_SYSTEM); 102 103 if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE) { 104 return true; 105 } 106 107 FhirContext ctx = theRequestDetails.getServer().getFhirContext(); 108 RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName()); 109 HashMap<String, List<String>> parameterToOrValues = new HashMap<>(); 110 AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails); 111 if (authorizedList == null) { 112 return true; 113 } 114 115 /* 116 * Create a map of search parameter values that need to be added to the 117 * given request 118 */ 119 Collection<String> compartments = authorizedList.getAllowedCompartments(); 120 if (compartments != null) { 121 processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, compartments, true); 122 } 123 Collection<String> resources = authorizedList.getAllowedInstances(); 124 if (resources != null) { 125 processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, resources, false); 126 } 127 128 /* 129 * Add any param values to the actual request 130 */ 131 if (parameterToOrValues.size() > 0) { 132 Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters()); 133 for (Map.Entry<String, List<String>> nextEntry : parameterToOrValues.entrySet()) { 134 String nextParamName = nextEntry.getKey(); 135 List<String> nextAllowedValues = nextEntry.getValue(); 136 137 if (!newParameters.containsKey(nextParamName)) { 138 139 /* 140 * If we don't already have a parameter of the given type, add one 141 */ 142 String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues); 143 String[] paramValues = {nextValuesJoined}; 144 newParameters.put(nextParamName, paramValues); 145 146 } else { 147 148 /* 149 * If the client explicitly requested the given parameter already, we'll 150 * just update the request to have the intersection of the values that the client 151 * requested, and the values that the user is allowed to see 152 */ 153 String[] existingValues = newParameters.get(nextParamName); 154 List<String> nextAllowedValueIds = nextAllowedValues 155 .stream() 156 .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t) 157 .collect(Collectors.toList()); 158 boolean restrictedExistingList = false; 159 for (int i = 0; i < existingValues.length; i++) { 160 161 String nextExistingValue = existingValues[i]; 162 List<String> nextRequestedValues = QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue); 163 List<String> nextPermittedValues = ListUtils.union( 164 ListUtils.intersection(nextRequestedValues, nextAllowedValues), 165 ListUtils.intersection(nextRequestedValues, nextAllowedValueIds) 166 ); 167 if (nextPermittedValues.size() > 0) { 168 restrictedExistingList = true; 169 existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues); 170 } 171 172 } 173 174 /* 175 * If none of the values that were requested by the client overlap at all 176 * with the values that the user is allowed to see, the client shouldn't 177 * get *any* results back. We return an error code indicating that the 178 * caller is forbidden from accessing the resources they requested. 179 */ 180 if (!restrictedExistingList) { 181 theResponse.setStatus(Constants.STATUS_HTTP_403_FORBIDDEN); 182 return false; 183 } 184 } 185 186 } 187 theRequestDetails.setParameters(newParameters); 188 } 189 190 return true; 191 } 192 193 @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) 194 public void incomingRequestPreHandled(ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException { 195 if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.TRANSACTION) { 196 return; 197 } 198 199 IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource(); 200 FhirContext ctx = theRequestDetails.getFhirContext(); 201 BundleEntryUrlProcessor processor = new BundleEntryUrlProcessor(ctx, theRequestDetails, theRequest, theResponse); 202 BundleUtil.processEntries(ctx, bundle, processor); 203 } 204 205 private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> { 206 private final FhirContext myFhirContext; 207 private final ServletRequestDetails myRequestDetails; 208 private final HttpServletRequest myRequest; 209 private final HttpServletResponse myResponse; 210 211 public BundleEntryUrlProcessor(FhirContext theFhirContext, ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) { 212 myFhirContext = theFhirContext; 213 myRequestDetails = theRequestDetails; 214 myRequest = theRequest; 215 myResponse = theResponse; 216 } 217 218 @Override 219 public void accept(ModifiableBundleEntry theModifiableBundleEntry) { 220 ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create(); 221 222 String url = theModifiableBundleEntry.getRequestUrl(); 223 224 ServletSubRequestDetails subServletRequestDetails = ServletRequestUtil.getServletSubRequestDetails(myRequestDetails, url, paramValues); 225 BaseMethodBinding<?> method = subServletRequestDetails.getServer().determineResourceMethod(subServletRequestDetails, url); 226 RestOperationTypeEnum restOperationType = method.getRestOperationType(); 227 subServletRequestDetails.setRestOperationType(restOperationType); 228 229 incomingRequestPostProcessed(subServletRequestDetails, myRequest, myResponse); 230 231 theModifiableBundleEntry.setRequestUrl(myFhirContext, ServletRequestUtil.extractUrl(subServletRequestDetails)); 232 } 233 } 234 235 private void processResourcesOrCompartments(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, HashMap<String, List<String>> theParameterToOrValues, Collection<String> theResourcesOrCompartments, boolean theAreCompartments) { 236 String lastCompartmentName = null; 237 String lastSearchParamName = null; 238 for (String nextCompartment : theResourcesOrCompartments) { 239 Validate.isTrue(StringUtils.countMatches(nextCompartment, '/') == 1, "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", nextCompartment); 240 String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/')); 241 242 String searchParamName = null; 243 if (compartmentName.equalsIgnoreCase(lastCompartmentName)) { 244 245 // Avoid doing a lookup for the same thing repeatedly 246 searchParamName = lastSearchParamName; 247 248 } else { 249 250 if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) { 251 252 searchParamName = "_id"; 253 254 } else if (theAreCompartments) { 255 256 searchParamName = selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName); 257 } 258 259 lastCompartmentName = compartmentName; 260 lastSearchParamName = searchParamName; 261 262 } 263 264 if (searchParamName != null) { 265 List<String> orValues = theParameterToOrValues.computeIfAbsent(searchParamName, t -> new ArrayList<>()); 266 orValues.add(nextCompartment); 267 } 268 } 269 } 270 271 private String selectBestSearchParameterForCompartment(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) { 272 String searchParamName = null; 273 274 Set<String> queryParameters = theRequestDetails.getParameters().keySet(); 275 276 List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName); 277 if (searchParams.size() > 0) { 278 279 // Resources like Observation have several fields that add the resource to 280 // the compartment. In the case of Observation, it's subject, patient and performer. 281 // For this kind of thing, we'll prefer the one that matches the compartment name. 282 Optional<RuntimeSearchParam> primarySearchParam = 283 searchParams 284 .stream() 285 .filter(t -> t.getName().equalsIgnoreCase(compartmentName)) 286 .findFirst(); 287 288 if (primarySearchParam.isPresent()) { 289 String primarySearchParamName = primarySearchParam.get().getName(); 290 // If the primary search parameter is actually in use in the query, use it. 291 if (queryParameters.contains(primarySearchParamName)) { 292 searchParamName = primarySearchParamName; 293 } else { 294 // If the primary search parameter itself isn't in use, check to see whether any of its synonyms are. 295 Optional<RuntimeSearchParam> synonymInUse = findSynonyms(searchParams, primarySearchParam.get()) 296 .stream() 297 .filter(t -> queryParameters.contains(t.getName())) 298 .findFirst(); 299 if (synonymInUse.isPresent()) { 300 // if a synonym is in use, use it 301 searchParamName = synonymInUse.get().getName(); 302 } else { 303 // if not, i.e., the original query is not filtering on this field at all, use the primary search param 304 searchParamName = primarySearchParamName; 305 } 306 } 307 } else { 308 // Otherwise, fall back to whatever search parameter is available 309 searchParamName = searchParams.get(0).getName(); 310 } 311 312 } 313 return searchParamName; 314 } 315 316 private List<RuntimeSearchParam> findSynonyms(List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) { 317 // We define two search parameters in a compartment as synonyms if they refer to the same field in the model, ignoring any qualifiers 318 319 String primaryBasePath = getBasePath(primarySearchParam); 320 321 return searchParams 322 .stream() 323 .filter(t -> primaryBasePath.equals(getBasePath(t))) 324 .collect(Collectors.toList()); 325 } 326 327 private String getBasePath(RuntimeSearchParam searchParam) { 328 int qualifierIndex = searchParam.getPath().indexOf(".where"); 329 if (qualifierIndex == -1) { 330 return searchParam.getPath(); 331 } else { 332 return searchParam.getPath().substring(0, qualifierIndex); 333 } 334 } 335 336}