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