001/*-
002 * #%L
003 * HAPI FHIR - Server Framework
004 * %%
005 * Copyright (C) 2014 - 2024 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.rest.server.interceptor.auth;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.context.support.IValidationSupport;
026import ca.uhn.fhir.context.support.ValidationSupportContext;
027import ca.uhn.fhir.context.support.ValueSetExpansionOptions;
028import ca.uhn.fhir.i18n.Msg;
029import ca.uhn.fhir.interceptor.api.Hook;
030import ca.uhn.fhir.interceptor.api.Pointcut;
031import ca.uhn.fhir.rest.api.Constants;
032import ca.uhn.fhir.rest.api.QualifiedParamList;
033import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
034import ca.uhn.fhir.rest.api.server.RequestDetails;
035import ca.uhn.fhir.rest.param.ParameterUtil;
036import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
037import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
038import ca.uhn.fhir.rest.server.method.BaseMethodBinding;
039import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
040import ca.uhn.fhir.rest.server.servlet.ServletSubRequestDetails;
041import ca.uhn.fhir.rest.server.util.ServletRequestUtil;
042import ca.uhn.fhir.util.BundleUtil;
043import ca.uhn.fhir.util.FhirTerser;
044import ca.uhn.fhir.util.UrlUtil;
045import ca.uhn.fhir.util.ValidateUtil;
046import ca.uhn.fhir.util.bundle.ModifiableBundleEntry;
047import com.google.common.collect.ArrayListMultimap;
048import jakarta.annotation.Nullable;
049import jakarta.servlet.http.HttpServletRequest;
050import jakarta.servlet.http.HttpServletResponse;
051import org.apache.commons.collections4.ListUtils;
052import org.apache.commons.lang3.StringUtils;
053import org.apache.commons.lang3.Validate;
054import org.hl7.fhir.instance.model.api.IBase;
055import org.hl7.fhir.instance.model.api.IBaseBundle;
056
057import java.util.ArrayList;
058import java.util.Arrays;
059import java.util.Collection;
060import java.util.HashMap;
061import java.util.List;
062import java.util.Map;
063import java.util.Optional;
064import java.util.Set;
065import java.util.function.Consumer;
066import java.util.stream.Collectors;
067
068/**
069 * This interceptor can be used to automatically narrow the scope of searches in order to
070 * automatically restrict the searches to specific compartments.
071 * <p>
072 * For example, this interceptor
073 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data
074 * in the <code>Patient/123</code> compartment). In this case, a user performing a search
075 * for<br/>
076 * <code>http://baseurl/Observation?category=laboratory</code><br/>
077 * would receive results as though they had requested<br/>
078 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code>
079 * </p>
080 * <p>
081 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor}
082 * if you are restricting results because of a security restriction. This interceptor is not
083 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the
084 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to
085 * help users simplify their queries while not receiving security errors for to trying to access
086 * data they do not have access to see.
087 * </p>
088 *
089 * @see AuthorizationInterceptor
090 */
091public class SearchNarrowingInterceptor {
092
093        public static final String POST_FILTERING_LIST_ATTRIBUTE_NAME =
094                        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(
113                                thePostFilterLargeValueSetThreshold > 0,
114                                "thePostFilterLargeValueSetThreshold must be a positive integer");
115                myPostFilterLargeValueSetThreshold = thePostFilterLargeValueSetThreshold;
116        }
117
118        /**
119         * Supplies a validation support module that will be used to apply the
120         *
121         * @see #setPostFilterLargeValueSetThreshold(int)
122         * @since 6.0.0
123         */
124        public SearchNarrowingInterceptor setValidationSupport(IValidationSupport theValidationSupport) {
125                myValidationSupport = theValidationSupport;
126                return this;
127        }
128
129        /**
130         * Subclasses should override this method to supply the set of compartments that
131         * the user making the request should actually have access to.
132         * <p>
133         * Typically this is done by examining <code>theRequestDetails</code> to find
134         * out who the current user is and then building a list of Strings.
135         * </p>
136         *
137         * @param theRequestDetails The individual request currently being applied
138         * @return The list of allowed compartments and instances that should be used
139         * for search narrowing. If this method returns <code>null</code>, no narrowing will
140         * be performed
141         */
142        protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) {
143                return null;
144        }
145
146        @Hook(Pointcut.SERVER_INCOMING_REQUEST_POST_PROCESSED)
147        public boolean hookIncomingRequestPostProcessed(
148                        RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse)
149                        throws AuthenticationException {
150                // We don't support this operation type yet
151                Validate.isTrue(theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_SYSTEM);
152
153                // N.B do not add code above this for filtering, this should only ever occur on search.
154                if (shouldSkipNarrowing(theRequestDetails)) {
155                        return true;
156                }
157
158                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
159                if (authorizedList == null) {
160                        return true;
161                }
162
163                // Add rules to request so that the SearchNarrowingConsentService can pick them up
164                List<AllowedCodeInValueSet> postFilteringList = getPostFilteringList(theRequestDetails);
165                if (authorizedList.getAllowedCodeInValueSets() != null) {
166                        postFilteringList.addAll(authorizedList.getAllowedCodeInValueSets());
167                }
168
169                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
170                RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName());
171                /*
172                 * Create a map of search parameter values that need to be added to the
173                 * given request
174                 */
175                Collection<String> compartments = authorizedList.getAllowedCompartments();
176                if (compartments != null) {
177                        Map<String, List<String>> parameterToOrValues =
178                                        processResourcesOrCompartments(theRequestDetails, resDef, compartments, true);
179                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true);
180                }
181                Collection<String> resources = authorizedList.getAllowedInstances();
182                if (resources != null) {
183                        Map<String, List<String>> parameterToOrValues =
184                                        processResourcesOrCompartments(theRequestDetails, resDef, resources, false);
185                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true);
186                }
187                List<AllowedCodeInValueSet> allowedCodeInValueSet = authorizedList.getAllowedCodeInValueSets();
188                if (allowedCodeInValueSet != null) {
189                        Map<String, List<String>> parameterToOrValues = processAllowedCodes(resDef, allowedCodeInValueSet);
190                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, false);
191                }
192
193                return true;
194        }
195
196        /**
197         * Skip unless it is a search request or an $everything operation
198         */
199        private boolean shouldSkipNarrowing(RequestDetails theRequestDetails) {
200                return theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE
201                                && !"$everything".equalsIgnoreCase(theRequestDetails.getOperation());
202        }
203
204        @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
205        public void hookIncomingRequestPreHandled(
206                        ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse)
207                        throws AuthenticationException {
208                if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.TRANSACTION) {
209                        return;
210                }
211
212                IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource();
213                FhirContext ctx = theRequestDetails.getFhirContext();
214                BundleEntryUrlProcessor processor =
215                                new BundleEntryUrlProcessor(ctx, theRequestDetails, theRequest, theResponse);
216                BundleUtil.processEntries(ctx, bundle, processor);
217        }
218
219        private void applyParametersToRequestDetails(
220                        RequestDetails theRequestDetails,
221                        @Nullable Map<String, List<String>> theParameterToOrValues,
222                        boolean thePatientIdMode) {
223                if (theParameterToOrValues != null) {
224                        Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters());
225                        for (Map.Entry<String, List<String>> nextEntry : theParameterToOrValues.entrySet()) {
226                                String nextParamName = nextEntry.getKey();
227                                List<String> nextAllowedValues = nextEntry.getValue();
228
229                                if (!newParameters.containsKey(nextParamName)) {
230
231                                        /*
232                                         * If we don't already have a parameter of the given type, add one
233                                         */
234                                        String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
235                                        String[] paramValues = {nextValuesJoined};
236                                        newParameters.put(nextParamName, paramValues);
237
238                                } else {
239
240                                        /*
241                                         * If the client explicitly requested the given parameter already, we'll
242                                         * just update the request to have the intersection of the values that the client
243                                         * requested, and the values that the user is allowed to see
244                                         */
245                                        String[] existingValues = newParameters.get(nextParamName);
246
247                                        if (thePatientIdMode) {
248                                                List<String> nextAllowedValueIds = nextAllowedValues.stream()
249                                                                .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t)
250                                                                .collect(Collectors.toList());
251                                                boolean restrictedExistingList = false;
252                                                for (int i = 0; i < existingValues.length; i++) {
253
254                                                        String nextExistingValue = existingValues[i];
255                                                        List<String> nextRequestedValues =
256                                                                        QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue);
257                                                        List<String> nextPermittedValues = ListUtils.union(
258                                                                        ListUtils.intersection(nextRequestedValues, nextAllowedValues),
259                                                                        ListUtils.intersection(nextRequestedValues, nextAllowedValueIds));
260                                                        if (nextPermittedValues.size() > 0) {
261                                                                restrictedExistingList = true;
262                                                                existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues);
263                                                        }
264                                                }
265
266                                                /*
267                                                 * If none of the values that were requested by the client overlap at all
268                                                 * with the values that the user is allowed to see, the client shouldn't
269                                                 * get *any* results back. We return an error code indicating that the
270                                                 * caller is forbidden from accessing the resources they requested.
271                                                 */
272                                                if (!restrictedExistingList) {
273                                                        throw new ForbiddenOperationException(Msg.code(2026) + "Value not permitted for parameter "
274                                                                        + UrlUtil.escapeUrlParam(nextParamName));
275                                                }
276
277                                        } else {
278
279                                                int existingValuesCount = existingValues.length;
280                                                String[] newValues =
281                                                                Arrays.copyOf(existingValues, existingValuesCount + nextAllowedValues.size());
282                                                for (int i = 0; i < nextAllowedValues.size(); i++) {
283                                                        newValues[existingValuesCount + i] = nextAllowedValues.get(i);
284                                                }
285                                                newParameters.put(nextParamName, newValues);
286                                        }
287                                }
288                        }
289                        theRequestDetails.setParameters(newParameters);
290                }
291        }
292
293        @Nullable
294        private Map<String, List<String>> processResourcesOrCompartments(
295                        RequestDetails theRequestDetails,
296                        RuntimeResourceDefinition theResDef,
297                        Collection<String> theResourcesOrCompartments,
298                        boolean theAreCompartments) {
299                Map<String, List<String>> retVal = null;
300
301                String lastCompartmentName = null;
302                String lastSearchParamName = null;
303                for (String nextCompartment : theResourcesOrCompartments) {
304                        Validate.isTrue(
305                                        StringUtils.countMatches(nextCompartment, '/') == 1,
306                                        "Invalid compartment name (must be in form \"ResourceType/xxx\": %s",
307                                        nextCompartment);
308                        String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/'));
309
310                        String searchParamName = null;
311                        if (compartmentName.equalsIgnoreCase(lastCompartmentName)) {
312
313                                // Avoid doing a lookup for the same thing repeatedly
314                                searchParamName = lastSearchParamName;
315
316                        } else {
317
318                                if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) {
319
320                                        searchParamName = "_id";
321
322                                } else if (theAreCompartments) {
323
324                                        searchParamName =
325                                                        selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName);
326                                }
327
328                                lastCompartmentName = compartmentName;
329                                lastSearchParamName = searchParamName;
330                        }
331
332                        if (searchParamName != null) {
333                                if (retVal == null) {
334                                        retVal = new HashMap<>();
335                                }
336                                List<String> orValues = retVal.computeIfAbsent(searchParamName, t -> new ArrayList<>());
337                                orValues.add(nextCompartment);
338                        }
339                }
340
341                return retVal;
342        }
343
344        @Nullable
345        private Map<String, List<String>> processAllowedCodes(
346                        RuntimeResourceDefinition theResDef, List<AllowedCodeInValueSet> theAllowedCodeInValueSet) {
347                Map<String, List<String>> retVal = null;
348
349                for (AllowedCodeInValueSet next : theAllowedCodeInValueSet) {
350                        String resourceName = next.getResourceName();
351                        String valueSetUrl = next.getValueSetUrl();
352
353                        ValidateUtil.isNotBlankOrThrowIllegalArgument(
354                                        resourceName, "Resource name supplied by SearchNarrowingInterceptor must not be null");
355                        ValidateUtil.isNotBlankOrThrowIllegalArgument(
356                                        valueSetUrl, "ValueSet URL supplied by SearchNarrowingInterceptor must not be null");
357
358                        if (!resourceName.equals(theResDef.getName())) {
359                                continue;
360                        }
361
362                        if (shouldHandleThroughConsentService(valueSetUrl)) {
363                                continue;
364                        }
365
366                        String paramName;
367                        if (next.isNegate()) {
368                                paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_NOT_IN;
369                        } else {
370                                paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_IN;
371                        }
372
373                        if (retVal == null) {
374                                retVal = new HashMap<>();
375                        }
376                        retVal.computeIfAbsent(paramName, k -> new ArrayList<>()).add(valueSetUrl);
377                }
378
379                return retVal;
380        }
381
382        /**
383         * For a given ValueSet URL, expand the valueset and check if the number of
384         * codes present is larger than the post filter threshold.
385         */
386        private boolean shouldHandleThroughConsentService(String theValueSetUrl) {
387                if (myValidationSupport != null && myPostFilterLargeValueSetThreshold != -1) {
388                        ValidationSupportContext ctx = new ValidationSupportContext(myValidationSupport);
389                        ValueSetExpansionOptions options = new ValueSetExpansionOptions();
390                        options.setCount(myPostFilterLargeValueSetThreshold);
391                        options.setIncludeHierarchy(false);
392                        IValidationSupport.ValueSetExpansionOutcome outcome =
393                                        myValidationSupport.expandValueSet(ctx, options, theValueSetUrl);
394                        if (outcome != null && outcome.getValueSet() != null) {
395                                FhirTerser terser = myValidationSupport.getFhirContext().newTerser();
396                                List<IBase> contains = terser.getValues(outcome.getValueSet(), "ValueSet.expansion.contains");
397                                int codeCount = contains.size();
398                                return codeCount >= myPostFilterLargeValueSetThreshold;
399                        }
400                }
401                return false;
402        }
403
404        private String selectBestSearchParameterForCompartment(
405                        RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) {
406                String searchParamName = null;
407
408                Set<String> queryParameters = theRequestDetails.getParameters().keySet();
409
410                List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName);
411                if (searchParams.size() > 0) {
412
413                        // Resources like Observation have several fields that add the resource to
414                        // the compartment. In the case of Observation, it's subject, patient and performer.
415                        // For this kind of thing, we'll prefer the one that matches the compartment name.
416                        Optional<RuntimeSearchParam> primarySearchParam = searchParams.stream()
417                                        .filter(t -> t.getName().equalsIgnoreCase(compartmentName))
418                                        .findFirst();
419
420                        if (primarySearchParam.isPresent()) {
421                                String primarySearchParamName = primarySearchParam.get().getName();
422                                // If the primary search parameter is actually in use in the query, use it.
423                                if (queryParameters.contains(primarySearchParamName)) {
424                                        searchParamName = primarySearchParamName;
425                                } else {
426                                        // If the primary search parameter itself isn't in use, check to see whether any of its synonyms
427                                        // are.
428                                        Optional<RuntimeSearchParam> synonymInUse =
429                                                        findSynonyms(searchParams, primarySearchParam.get()).stream()
430                                                                        .filter(t -> queryParameters.contains(t.getName()))
431                                                                        .findFirst();
432                                        if (synonymInUse.isPresent()) {
433                                                // if a synonym is in use, use it
434                                                searchParamName = synonymInUse.get().getName();
435                                        } else {
436                                                // if not, i.e., the original query is not filtering on this field at all, use the primary
437                                                // search param
438                                                searchParamName = primarySearchParamName;
439                                        }
440                                }
441                        } else {
442                                // Otherwise, fall back to whatever search parameter is available
443                                searchParamName = searchParams.get(0).getName();
444                        }
445                }
446                return searchParamName;
447        }
448
449        private List<RuntimeSearchParam> findSynonyms(
450                        List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) {
451                // We define two search parameters in a compartment as synonyms if they refer to the same field in the model,
452                // ignoring any qualifiers
453
454                String primaryBasePath = getBasePath(primarySearchParam);
455
456                return searchParams.stream()
457                                .filter(t -> primaryBasePath.equals(getBasePath(t)))
458                                .collect(Collectors.toList());
459        }
460
461        private String getBasePath(RuntimeSearchParam searchParam) {
462                int qualifierIndex = searchParam.getPath().indexOf(".where");
463                if (qualifierIndex == -1) {
464                        return searchParam.getPath();
465                } else {
466                        return searchParam.getPath().substring(0, qualifierIndex);
467                }
468        }
469
470        private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> {
471                private final FhirContext myFhirContext;
472                private final ServletRequestDetails myRequestDetails;
473                private final HttpServletRequest myRequest;
474                private final HttpServletResponse myResponse;
475
476                public BundleEntryUrlProcessor(
477                                FhirContext theFhirContext,
478                                ServletRequestDetails theRequestDetails,
479                                HttpServletRequest theRequest,
480                                HttpServletResponse theResponse) {
481                        myFhirContext = theFhirContext;
482                        myRequestDetails = theRequestDetails;
483                        myRequest = theRequest;
484                        myResponse = theResponse;
485                }
486
487                @Override
488                public void accept(ModifiableBundleEntry theModifiableBundleEntry) {
489                        ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create();
490
491                        String url = theModifiableBundleEntry.getRequestUrl();
492
493                        ServletSubRequestDetails subServletRequestDetails =
494                                        ServletRequestUtil.getServletSubRequestDetails(myRequestDetails, url, paramValues);
495                        BaseMethodBinding method =
496                                        subServletRequestDetails.getServer().determineResourceMethod(subServletRequestDetails, url);
497                        RestOperationTypeEnum restOperationType = method.getRestOperationType();
498                        subServletRequestDetails.setRestOperationType(restOperationType);
499
500                        hookIncomingRequestPostProcessed(subServletRequestDetails, myRequest, myResponse);
501
502                        theModifiableBundleEntry.setRequestUrl(
503                                        myFhirContext, ServletRequestUtil.extractUrl(subServletRequestDetails));
504                }
505        }
506
507        static List<AllowedCodeInValueSet> getPostFilteringList(RequestDetails theRequestDetails) {
508                List<AllowedCodeInValueSet> retVal = getPostFilteringListOrNull(theRequestDetails);
509                if (retVal == null) {
510                        retVal = new ArrayList<>();
511                        theRequestDetails.setAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME, retVal);
512                }
513                return retVal;
514        }
515
516        @SuppressWarnings("unchecked")
517        static List<AllowedCodeInValueSet> getPostFilteringListOrNull(RequestDetails theRequestDetails) {
518                return (List<AllowedCodeInValueSet>) theRequestDetails.getAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME);
519        }
520}