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