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}