001package ca.uhn.fhir.rest.server.interceptor.auth;
002
003/*-
004 * #%L
005 * HAPI FHIR - Server Framework
006 * %%
007 * Copyright (C) 2014 - 2023 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                //N.B do not add code above this for filtering, this should only ever occur on search.
150                if (shouldSkipNarrowing(theRequestDetails)) {
151                        return true;
152                }
153
154                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
155                if (authorizedList == null) {
156                        return true;
157                }
158
159                // Add rules to request so that the SearchNarrowingConsentService can pick them up
160                List<AllowedCodeInValueSet> postFilteringList = getPostFilteringList(theRequestDetails);
161                if (authorizedList.getAllowedCodeInValueSets() != null) {
162                        postFilteringList.addAll(authorizedList.getAllowedCodeInValueSets());
163                }
164
165
166                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
167                RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName());
168                /*
169                 * Create a map of search parameter values that need to be added to the
170                 * given request
171                 */
172                Collection<String> compartments = authorizedList.getAllowedCompartments();
173                if (compartments != null) {
174                        Map<String, List<String>> parameterToOrValues = processResourcesOrCompartments(theRequestDetails, resDef, compartments, true);
175                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true);
176                }
177                Collection<String> resources = authorizedList.getAllowedInstances();
178                if (resources != null) {
179                        Map<String, List<String>> parameterToOrValues = processResourcesOrCompartments(theRequestDetails, resDef, resources, false);
180                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, true);
181                }
182                List<AllowedCodeInValueSet> allowedCodeInValueSet = authorizedList.getAllowedCodeInValueSets();
183                if (allowedCodeInValueSet != null) {
184                        Map<String, List<String>> parameterToOrValues = processAllowedCodes(resDef, allowedCodeInValueSet);
185                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, false);
186                }
187
188                return true;
189        }
190
191
192        /**
193         * Skip unless it is a search request or an $everything operation
194         */
195        private boolean shouldSkipNarrowing(RequestDetails theRequestDetails) {
196                return theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE
197                        && !"$everything".equalsIgnoreCase(theRequestDetails.getOperation());
198        }
199
200        @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
201        public void hookIncomingRequestPreHandled(ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException {
202                if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.TRANSACTION) {
203                        return;
204                }
205
206                IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource();
207                FhirContext ctx = theRequestDetails.getFhirContext();
208                BundleEntryUrlProcessor processor = new BundleEntryUrlProcessor(ctx, theRequestDetails, theRequest, theResponse);
209                BundleUtil.processEntries(ctx, bundle, processor);
210        }
211
212        private void applyParametersToRequestDetails(RequestDetails theRequestDetails, @Nullable Map<String, List<String>> theParameterToOrValues, boolean thePatientIdMode) {
213                if (theParameterToOrValues != null) {
214                        Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters());
215                        for (Map.Entry<String, List<String>> nextEntry : theParameterToOrValues.entrySet()) {
216                                String nextParamName = nextEntry.getKey();
217                                List<String> nextAllowedValues = nextEntry.getValue();
218
219                                if (!newParameters.containsKey(nextParamName)) {
220
221                                        /*
222                                         * If we don't already have a parameter of the given type, add one
223                                         */
224                                        String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
225                                        String[] paramValues = {nextValuesJoined};
226                                        newParameters.put(nextParamName, paramValues);
227
228                                } else {
229
230                                        /*
231                                         * If the client explicitly requested the given parameter already, we'll
232                                         * just update the request to have the intersection of the values that the client
233                                         * requested, and the values that the user is allowed to see
234                                         */
235                                        String[] existingValues = newParameters.get(nextParamName);
236
237                                        if (thePatientIdMode) {
238                                                List<String> nextAllowedValueIds = nextAllowedValues
239                                                        .stream()
240                                                        .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t)
241                                                        .collect(Collectors.toList());
242                                                boolean restrictedExistingList = false;
243                                                for (int i = 0; i < existingValues.length; i++) {
244
245                                                        String nextExistingValue = existingValues[i];
246                                                        List<String> nextRequestedValues = QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue);
247                                                        List<String> nextPermittedValues = ListUtils.union(
248                                                                ListUtils.intersection(nextRequestedValues, nextAllowedValues),
249                                                                ListUtils.intersection(nextRequestedValues, nextAllowedValueIds)
250                                                        );
251                                                        if (nextPermittedValues.size() > 0) {
252                                                                restrictedExistingList = true;
253                                                                existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues);
254                                                        }
255
256                                                }
257
258                                                /*
259                                                 * If none of the values that were requested by the client overlap at all
260                                                 * with the values that the user is allowed to see, the client shouldn't
261                                                 * get *any* results back. We return an error code indicating that the
262                                                 * caller is forbidden from accessing the resources they requested.
263                                                 */
264                                                if (!restrictedExistingList) {
265                                                        throw new ForbiddenOperationException(Msg.code(2026) + "Value not permitted for parameter " + UrlUtil.escapeUrlParam(nextParamName));
266                                                }
267
268                                        } else {
269
270                                                int existingValuesCount = existingValues.length;
271                                                String[] newValues = Arrays.copyOf(existingValues, existingValuesCount + nextAllowedValues.size());
272                                                for (int i = 0; i < nextAllowedValues.size(); i++) {
273                                                        newValues[existingValuesCount + i] = nextAllowedValues.get(i);
274                                                }
275                                                newParameters.put(nextParamName, newValues);
276
277                                        }
278
279                                }
280
281                        }
282                        theRequestDetails.setParameters(newParameters);
283                }
284        }
285
286        @Nullable
287        private Map<String, List<String>> processResourcesOrCompartments(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, Collection<String> theResourcesOrCompartments, boolean theAreCompartments) {
288                Map<String, List<String>> retVal = null;
289
290                String lastCompartmentName = null;
291                String lastSearchParamName = null;
292                for (String nextCompartment : theResourcesOrCompartments) {
293                        Validate.isTrue(StringUtils.countMatches(nextCompartment, '/') == 1, "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", nextCompartment);
294                        String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/'));
295
296                        String searchParamName = null;
297                        if (compartmentName.equalsIgnoreCase(lastCompartmentName)) {
298
299                                // Avoid doing a lookup for the same thing repeatedly
300                                searchParamName = lastSearchParamName;
301
302                        } else {
303
304                                if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) {
305
306                                        searchParamName = "_id";
307
308                                } else if (theAreCompartments) {
309
310                                        searchParamName = selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName);
311                                }
312
313                                lastCompartmentName = compartmentName;
314                                lastSearchParamName = searchParamName;
315
316                        }
317
318                        if (searchParamName != null) {
319                                if (retVal == null) {
320                                        retVal = new HashMap<>();
321                                }
322                                List<String> orValues = retVal.computeIfAbsent(searchParamName, t -> new ArrayList<>());
323                                orValues.add(nextCompartment);
324                        }
325                }
326
327                return retVal;
328        }
329
330        @Nullable
331        private Map<String, List<String>> processAllowedCodes(RuntimeResourceDefinition theResDef, List<AllowedCodeInValueSet> theAllowedCodeInValueSet) {
332                Map<String, List<String>> retVal = null;
333
334                for (AllowedCodeInValueSet next : theAllowedCodeInValueSet) {
335                        String resourceName = next.getResourceName();
336                        String valueSetUrl = next.getValueSetUrl();
337
338                        ValidateUtil.isNotBlankOrThrowIllegalArgument(resourceName, "Resource name supplied by SearchNarrowingInterceptor must not be null");
339                        ValidateUtil.isNotBlankOrThrowIllegalArgument(valueSetUrl, "ValueSet URL supplied by SearchNarrowingInterceptor must not be null");
340
341                        if (!resourceName.equals(theResDef.getName())) {
342                                continue;
343                        }
344
345                        if (shouldHandleThroughConsentService(valueSetUrl)) {
346                                continue;
347                        }
348
349                        String paramName;
350                        if (next.isNegate()) {
351                                paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_NOT_IN;
352                        } else {
353                                paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_IN;
354                        }
355
356                        if (retVal == null) {
357                                retVal = new HashMap<>();
358                        }
359                        retVal.computeIfAbsent(paramName, k -> new ArrayList<>()).add(valueSetUrl);
360                }
361
362                return retVal;
363        }
364
365        /**
366         * For a given ValueSet URL, expand the valueset and check if the number of
367         * codes present is larger than the post filter threshold.
368         */
369        private boolean shouldHandleThroughConsentService(String theValueSetUrl) {
370                if (myValidationSupport != null && myPostFilterLargeValueSetThreshold != -1) {
371                        ValidationSupportContext ctx = new ValidationSupportContext(myValidationSupport);
372                        ValueSetExpansionOptions options = new ValueSetExpansionOptions();
373                        options.setCount(myPostFilterLargeValueSetThreshold);
374                        options.setIncludeHierarchy(false);
375                        IValidationSupport.ValueSetExpansionOutcome outcome = myValidationSupport.expandValueSet(ctx, options, theValueSetUrl);
376                        if (outcome != null && outcome.getValueSet() != null) {
377                                FhirTerser terser = myValidationSupport.getFhirContext().newTerser();
378                                List<IBase> contains = terser.getValues(outcome.getValueSet(), "ValueSet.expansion.contains");
379                                int codeCount = contains.size();
380                                return codeCount >= myPostFilterLargeValueSetThreshold;
381                        }
382                }
383                return false;
384        }
385
386
387        private String selectBestSearchParameterForCompartment(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) {
388                String searchParamName = null;
389
390                Set<String> queryParameters = theRequestDetails.getParameters().keySet();
391
392                List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName);
393                if (searchParams.size() > 0) {
394
395                        // Resources like Observation have several fields that add the resource to
396                        // the compartment. In the case of Observation, it's subject, patient and performer.
397                        // For this kind of thing, we'll prefer the one that matches the compartment name.
398                        Optional<RuntimeSearchParam> primarySearchParam =
399                                searchParams
400                                        .stream()
401                                        .filter(t -> t.getName().equalsIgnoreCase(compartmentName))
402                                        .findFirst();
403
404                        if (primarySearchParam.isPresent()) {
405                                String primarySearchParamName = primarySearchParam.get().getName();
406                                // If the primary search parameter is actually in use in the query, use it.
407                                if (queryParameters.contains(primarySearchParamName)) {
408                                        searchParamName = primarySearchParamName;
409                                } else {
410                                        // If the primary search parameter itself isn't in use, check to see whether any of its synonyms are.
411                                        Optional<RuntimeSearchParam> synonymInUse = findSynonyms(searchParams, primarySearchParam.get())
412                                                .stream()
413                                                .filter(t -> queryParameters.contains(t.getName()))
414                                                .findFirst();
415                                        if (synonymInUse.isPresent()) {
416                                                // if a synonym is in use, use it
417                                                searchParamName = synonymInUse.get().getName();
418                                        } else {
419                                                // if not, i.e., the original query is not filtering on this field at all, use the primary search param
420                                                searchParamName = primarySearchParamName;
421                                        }
422                                }
423                        } else {
424                                // Otherwise, fall back to whatever search parameter is available
425                                searchParamName = searchParams.get(0).getName();
426                        }
427
428                }
429                return searchParamName;
430        }
431
432        private List<RuntimeSearchParam> findSynonyms(List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) {
433                // We define two search parameters in a compartment as synonyms if they refer to the same field in the model, ignoring any qualifiers
434
435                String primaryBasePath = getBasePath(primarySearchParam);
436
437                return searchParams
438                        .stream()
439                        .filter(t -> primaryBasePath.equals(getBasePath(t)))
440                        .collect(Collectors.toList());
441        }
442
443        private String getBasePath(RuntimeSearchParam searchParam) {
444                int qualifierIndex = searchParam.getPath().indexOf(".where");
445                if (qualifierIndex == -1) {
446                        return searchParam.getPath();
447                } else {
448                        return searchParam.getPath().substring(0, qualifierIndex);
449                }
450        }
451
452        private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> {
453                private final FhirContext myFhirContext;
454                private final ServletRequestDetails myRequestDetails;
455                private final HttpServletRequest myRequest;
456                private final HttpServletResponse myResponse;
457
458                public BundleEntryUrlProcessor(FhirContext theFhirContext, ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) {
459                        myFhirContext = theFhirContext;
460                        myRequestDetails = theRequestDetails;
461                        myRequest = theRequest;
462                        myResponse = theResponse;
463                }
464
465                @Override
466                public void accept(ModifiableBundleEntry theModifiableBundleEntry) {
467                        ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create();
468
469                        String url = theModifiableBundleEntry.getRequestUrl();
470
471                        ServletSubRequestDetails subServletRequestDetails = ServletRequestUtil.getServletSubRequestDetails(myRequestDetails, url, paramValues);
472                        BaseMethodBinding method = subServletRequestDetails.getServer().determineResourceMethod(subServletRequestDetails, url);
473                        RestOperationTypeEnum restOperationType = method.getRestOperationType();
474                        subServletRequestDetails.setRestOperationType(restOperationType);
475
476                        hookIncomingRequestPostProcessed(subServletRequestDetails, myRequest, myResponse);
477
478                        theModifiableBundleEntry.setRequestUrl(myFhirContext, ServletRequestUtil.extractUrl(subServletRequestDetails));
479                }
480        }
481
482
483        static List<AllowedCodeInValueSet> getPostFilteringList(RequestDetails theRequestDetails) {
484                List<AllowedCodeInValueSet> retVal = getPostFilteringListOrNull(theRequestDetails);
485                if (retVal == null) {
486                        retVal = new ArrayList<>();
487                        theRequestDetails.setAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME, retVal);
488                }
489                return retVal;
490        }
491
492        @SuppressWarnings("unchecked")
493        static List<AllowedCodeInValueSet> getPostFilteringListOrNull(RequestDetails theRequestDetails) {
494                return (List<AllowedCodeInValueSet>) theRequestDetails.getAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME);
495        }
496
497
498}