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}