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.RequestTypeEnum;
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.servlet.ServletRequestDetails;
040import ca.uhn.fhir.util.BundleUtil;
041import ca.uhn.fhir.util.FhirTerser;
042import ca.uhn.fhir.util.UrlUtil;
043import ca.uhn.fhir.util.ValidateUtil;
044import ca.uhn.fhir.util.bundle.ModifiableBundleEntry;
045import com.google.common.collect.ListMultimap;
046import com.google.common.collect.MultimapBuilder;
047import jakarta.annotation.Nonnull;
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
068import static org.apache.commons.lang3.StringUtils.isNotBlank;
069
070/**
071 * This interceptor can be used to automatically narrow the scope of searches in order to
072 * automatically restrict the searches to specific compartments.
073 * <p>
074 * For example, this interceptor
075 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data
076 * in the <code>Patient/123</code> compartment). In this case, a user performing a search
077 * for<br/>
078 * <code>http://baseurl/Observation?category=laboratory</code><br/>
079 * would receive results as though they had requested<br/>
080 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code>
081 * </p>
082 * <p>
083 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor}
084 * if you are restricting results because of a security restriction. This interceptor is not
085 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the
086 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to
087 * help users simplify their queries while not receiving security errors for to trying to access
088 * data they do not have access to see.
089 * </p>
090 *
091 * @see AuthorizationInterceptor
092 */
093@SuppressWarnings("JavadocLinkAsPlainText")
094public class SearchNarrowingInterceptor {
095
096        public static final String POST_FILTERING_LIST_ATTRIBUTE_NAME =
097                        SearchNarrowingInterceptor.class.getName() + "_POST_FILTERING_LIST";
098        private IValidationSupport myValidationSupport;
099        private int myPostFilterLargeValueSetThreshold = 500;
100        private boolean myNarrowConditionalUrls;
101
102        /**
103         * If set to {@literal true} (default is {@literal false}), conditional URLs such
104         * as the If-None-Exist header used for Conditional Create operations will
105         * also be narrowed.
106         *
107         * @param theNarrowConditionalUrls Should we narrow conditional URLs in requests
108         * @since 7.2.0
109         */
110        public void setNarrowConditionalUrls(boolean theNarrowConditionalUrls) {
111                myNarrowConditionalUrls = theNarrowConditionalUrls;
112        }
113
114        /**
115         * Supplies a threshold over which any ValueSet-based rules will be applied by
116         *
117         *
118         * <p>
119         * Note that this setting will have no effect if {@link #setValidationSupport(IValidationSupport)}
120         * has not also been called in order to supply a validation support module for
121         * testing ValueSet membership.
122         * </p>
123         *
124         * @param thePostFilterLargeValueSetThreshold The threshold
125         * @see #setValidationSupport(IValidationSupport)
126         */
127        public void setPostFilterLargeValueSetThreshold(int thePostFilterLargeValueSetThreshold) {
128                Validate.isTrue(
129                                thePostFilterLargeValueSetThreshold > 0,
130                                "thePostFilterLargeValueSetThreshold must be a positive integer");
131                myPostFilterLargeValueSetThreshold = thePostFilterLargeValueSetThreshold;
132        }
133
134        /**
135         * Supplies a validation support module that will be used to apply the
136         *
137         * @see #setPostFilterLargeValueSetThreshold(int)
138         * @since 6.0.0
139         */
140        public SearchNarrowingInterceptor setValidationSupport(IValidationSupport theValidationSupport) {
141                myValidationSupport = theValidationSupport;
142                return this;
143        }
144
145        /**
146         * This method handles narrowing for FHIR search/create/update/patch operations.
147         *
148         * @see #hookIncomingRequestPreHandled(ServletRequestDetails, HttpServletRequest, HttpServletResponse) This method narrows FHIR transaction bundles
149         */
150        @SuppressWarnings("EnumSwitchStatementWhichMissesCases")
151        @Hook(Pointcut.SERVER_INCOMING_REQUEST_POST_PROCESSED)
152        public void hookIncomingRequestPostProcessed(
153                        RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse)
154                        throws AuthenticationException {
155
156                // We don't support this operation type yet
157                RestOperationTypeEnum restOperationType = theRequestDetails.getRestOperationType();
158                Validate.isTrue(restOperationType != RestOperationTypeEnum.SEARCH_SYSTEM);
159
160                switch (restOperationType) {
161                        case EXTENDED_OPERATION_INSTANCE:
162                        case EXTENDED_OPERATION_TYPE: {
163                                if ("$everything".equals(theRequestDetails.getOperation())) {
164                                        narrowEverythingOperation(theRequestDetails);
165                                }
166                                break;
167                        }
168                        case SEARCH_TYPE:
169                                narrowTypeSearch(theRequestDetails);
170                                break;
171                        case CREATE:
172                                narrowIfNoneExistHeader(theRequestDetails);
173                                break;
174                        case DELETE:
175                        case UPDATE:
176                        case PATCH:
177                                narrowRequestUrl(theRequestDetails, restOperationType);
178                                break;
179                }
180        }
181
182        /**
183         * This method narrows FHIR transaction operations (because this pointcut
184         * is called after the request body is parsed).
185         *
186         * @see #hookIncomingRequestPostProcessed(RequestDetails, HttpServletRequest, HttpServletResponse) This method narrows FHIR search/create/update/etc operations
187         */
188        @SuppressWarnings("EnumSwitchStatementWhichMissesCases")
189        @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
190        public void hookIncomingRequestPreHandled(
191                        ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse)
192                        throws AuthenticationException {
193
194                if (theRequestDetails.getRestOperationType() != null) {
195                        switch (theRequestDetails.getRestOperationType()) {
196                                case TRANSACTION:
197                                case BATCH:
198                                        IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource();
199                                        FhirContext ctx = theRequestDetails.getFhirContext();
200                                        BundleEntryUrlProcessor processor = new BundleEntryUrlProcessor(ctx, theRequestDetails);
201                                        BundleUtil.processEntries(ctx, bundle, processor);
202                                        break;
203                        }
204                }
205        }
206
207        /**
208         * Subclasses should override this method to supply the set of compartments that
209         * the user making the request should actually have access to.
210         * <p>
211         * Typically this is done by examining <code>theRequestDetails</code> to find
212         * out who the current user is and then building a list of Strings.
213         * </p>
214         *
215         * @param theRequestDetails The individual request currently being applied
216         * @return The list of allowed compartments and instances that should be used
217         * for search narrowing. If this method returns <code>null</code>, no narrowing will
218         * be performed
219         */
220        protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) {
221                return null;
222        }
223
224        /**
225         * For the $everything operation, we only do code narrowing, and in this case
226         * we're not actually even making any changes to the request. All we do here is
227         * ensure that an attribute is added to the request, which is picked up later
228         * by {@link SearchNarrowingConsentService}.
229         */
230        private void narrowEverythingOperation(RequestDetails theRequestDetails) {
231                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
232                if (authorizedList != null) {
233                        buildParameterListForAuthorizedCodes(
234                                        theRequestDetails, theRequestDetails.getResourceName(), authorizedList);
235                }
236        }
237
238        private void narrowIfNoneExistHeader(RequestDetails theRequestDetails) {
239                if (myNarrowConditionalUrls) {
240                        String ifNoneExist = theRequestDetails.getHeader(Constants.HEADER_IF_NONE_EXIST);
241                        if (isNotBlank(ifNoneExist)) {
242                                String newConditionalUrl = narrowConditionalUrlForCompartmentOnly(
243                                                theRequestDetails, ifNoneExist, true, theRequestDetails.getResourceName());
244                                if (newConditionalUrl != null) {
245                                        theRequestDetails.setHeaders(Constants.HEADER_IF_NONE_EXIST, List.of(newConditionalUrl));
246                                }
247                        }
248                }
249        }
250
251        private void narrowRequestUrl(RequestDetails theRequestDetails, RestOperationTypeEnum theRestOperationType) {
252                if (myNarrowConditionalUrls) {
253                        String conditionalUrl = theRequestDetails.getConditionalUrl(theRestOperationType);
254                        if (isNotBlank(conditionalUrl)) {
255                                String newConditionalUrl = narrowConditionalUrlForCompartmentOnly(
256                                                theRequestDetails, conditionalUrl, false, theRequestDetails.getResourceName());
257                                if (newConditionalUrl != null) {
258                                        String newCompleteUrl = theRequestDetails
259                                                                        .getCompleteUrl()
260                                                                        .substring(
261                                                                                        0,
262                                                                                        theRequestDetails.getCompleteUrl().indexOf('?') + 1)
263                                                        + newConditionalUrl;
264                                        theRequestDetails.setCompleteUrl(newCompleteUrl);
265                                }
266                        }
267                }
268        }
269
270        /**
271         * Does not narrow codes
272         */
273        @Nullable
274        private String narrowConditionalUrlForCompartmentOnly(
275                        RequestDetails theRequestDetails,
276                        @Nonnull String theConditionalUrl,
277                        boolean theIncludeUpToQuestionMarkInResponse,
278                        String theResourceName) {
279                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
280                return narrowConditionalUrl(
281                                theRequestDetails,
282                                theConditionalUrl,
283                                theIncludeUpToQuestionMarkInResponse,
284                                theResourceName,
285                                false,
286                                authorizedList);
287        }
288
289        @Nullable
290        private String narrowConditionalUrl(
291                        RequestDetails theRequestDetails,
292                        @Nonnull String theConditionalUrl,
293                        boolean theIncludeUpToQuestionMarkInResponse,
294                        String theResourceName,
295                        boolean theNarrowCodes,
296                        AuthorizedList theAuthorizedList) {
297                if (theAuthorizedList == null) {
298                        return null;
299                }
300
301                ListMultimap<String, String> parametersToAdd =
302                                buildParameterListForAuthorizedCompartment(theRequestDetails, theResourceName, theAuthorizedList);
303
304                if (theNarrowCodes) {
305                        ListMultimap<String, String> parametersToAddForCodes =
306                                        buildParameterListForAuthorizedCodes(theRequestDetails, theResourceName, theAuthorizedList);
307                        if (parametersToAdd == null) {
308                                parametersToAdd = parametersToAddForCodes;
309                        } else if (parametersToAddForCodes != null) {
310                                parametersToAdd.putAll(parametersToAddForCodes);
311                        }
312                }
313
314                String newConditionalUrl = null;
315                if (parametersToAdd != null) {
316
317                        String query = theConditionalUrl;
318                        int qMarkIndex = theConditionalUrl.indexOf('?');
319                        if (qMarkIndex != -1) {
320                                query = theConditionalUrl.substring(qMarkIndex + 1);
321                        }
322
323                        Map<String, String[]> inputParams = UrlUtil.parseQueryString(query);
324                        Map<String, String[]> newParameters = applyCompartmentParameters(parametersToAdd, true, inputParams);
325
326                        StringBuilder newUrl = new StringBuilder();
327                        if (theIncludeUpToQuestionMarkInResponse) {
328                                newUrl.append(qMarkIndex != -1 ? theConditionalUrl.substring(0, qMarkIndex + 1) : "?");
329                        }
330
331                        boolean first = true;
332                        for (Map.Entry<String, String[]> nextEntry : newParameters.entrySet()) {
333                                for (String nextValue : nextEntry.getValue()) {
334                                        if (isNotBlank(nextValue)) {
335                                                if (first) {
336                                                        first = false;
337                                                } else {
338                                                        newUrl.append("&");
339                                                }
340                                                newUrl.append(UrlUtil.escapeUrlParam(nextEntry.getKey()));
341                                                newUrl.append("=");
342                                                newUrl.append(UrlUtil.escapeUrlParam(nextValue));
343                                        }
344                                }
345                        }
346
347                        newConditionalUrl = newUrl.toString();
348                }
349                return newConditionalUrl;
350        }
351
352        private void narrowTypeSearch(RequestDetails theRequestDetails) {
353
354                // N.B do not add code above this for filtering, this should only ever occur on search.
355                if (shouldSkipNarrowing(theRequestDetails)) {
356                        return;
357                }
358
359                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
360                if (authorizedList == null) {
361                        return;
362                }
363
364                String resourceName = theRequestDetails.getResourceName();
365
366                // Narrow request URL for compartments
367                ListMultimap<String, String> parametersToAdd =
368                                buildParameterListForAuthorizedCompartment(theRequestDetails, resourceName, authorizedList);
369                if (parametersToAdd != null) {
370                        applyParametersToRequestDetails(theRequestDetails, parametersToAdd, true);
371                }
372
373                // Narrow request URL for codes - Add rules to request so that the SearchNarrowingConsentService can pick them
374                // up
375                ListMultimap<String, String> parameterToOrValues =
376                                buildParameterListForAuthorizedCodes(theRequestDetails, resourceName, authorizedList);
377                if (parameterToOrValues != null) {
378                        applyParametersToRequestDetails(theRequestDetails, parameterToOrValues, false);
379                }
380        }
381
382        @Nullable
383        private ListMultimap<String, String> buildParameterListForAuthorizedCodes(
384                        RequestDetails theRequestDetails, String resourceName, AuthorizedList authorizedList) {
385                List<AllowedCodeInValueSet> postFilteringList = getPostFilteringList(theRequestDetails);
386                if (authorizedList.getAllowedCodeInValueSets() != null) {
387                        postFilteringList.addAll(authorizedList.getAllowedCodeInValueSets());
388                }
389
390                List<AllowedCodeInValueSet> allowedCodeInValueSet = authorizedList.getAllowedCodeInValueSets();
391                ListMultimap<String, String> parameterToOrValues = null;
392                if (allowedCodeInValueSet != null) {
393                        FhirContext context = theRequestDetails.getServer().getFhirContext();
394                        RuntimeResourceDefinition resourceDef = context.getResourceDefinition(resourceName);
395                        parameterToOrValues = processAllowedCodes(resourceDef, allowedCodeInValueSet);
396                }
397                return parameterToOrValues;
398        }
399
400        @Nullable
401        private ListMultimap<String, String> buildParameterListForAuthorizedCompartment(
402                        RequestDetails theRequestDetails, String theResourceName, @Nullable AuthorizedList theAuthorizedList) {
403                if (theAuthorizedList == null) {
404                        return null;
405                }
406
407                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
408                RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theResourceName);
409
410                /*
411                 * Create a map of search parameter values that need to be added to the
412                 * given request
413                 */
414                Collection<String> compartments = theAuthorizedList.getAllowedCompartments();
415                ListMultimap<String, String> parametersToAdd = null;
416                if (compartments != null) {
417                        parametersToAdd =
418                                        processResourcesOrCompartments(theRequestDetails, resDef, compartments, true, theResourceName);
419                }
420
421                Collection<String> resources = theAuthorizedList.getAllowedInstances();
422                if (resources != null) {
423                        ListMultimap<String, String> parameterToOrValues =
424                                        processResourcesOrCompartments(theRequestDetails, resDef, resources, false, theResourceName);
425                        if (parametersToAdd == null) {
426                                parametersToAdd = parameterToOrValues;
427                        } else if (parameterToOrValues != null) {
428                                parametersToAdd.putAll(parameterToOrValues);
429                        }
430                }
431                return parametersToAdd;
432        }
433
434        /**
435         * Skip unless it is a search request or an $everything operation
436         */
437        private boolean shouldSkipNarrowing(RequestDetails theRequestDetails) {
438                return theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE
439                                && !"$everything".equalsIgnoreCase(theRequestDetails.getOperation());
440        }
441
442        private void applyParametersToRequestDetails(
443                        RequestDetails theRequestDetails,
444                        @Nullable ListMultimap<String, String> theParameterToOrValues,
445                        boolean thePatientIdMode) {
446                Map<String, String[]> inputParameters = theRequestDetails.getParameters();
447                if (theParameterToOrValues != null) {
448                        Map<String, String[]> newParameters =
449                                        applyCompartmentParameters(theParameterToOrValues, thePatientIdMode, inputParameters);
450                        theRequestDetails.setParameters(newParameters);
451                }
452        }
453
454        @Nullable
455        private ListMultimap<String, String> processResourcesOrCompartments(
456                        RequestDetails theRequestDetails,
457                        RuntimeResourceDefinition theResDef,
458                        Collection<String> theResourcesOrCompartments,
459                        boolean theAreCompartments,
460                        String theResourceName) {
461                ListMultimap<String, String> retVal = null;
462
463                String lastCompartmentName = null;
464                String lastSearchParamName = null;
465                for (String nextCompartment : theResourcesOrCompartments) {
466                        Validate.isTrue(
467                                        StringUtils.countMatches(nextCompartment, '/') == 1,
468                                        "Invalid compartment name (must be in form \"ResourceType/xxx\": %s",
469                                        nextCompartment);
470                        String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/'));
471
472                        String searchParamName = null;
473                        if (compartmentName.equalsIgnoreCase(lastCompartmentName)) {
474
475                                // Avoid doing a lookup for the same thing repeatedly
476                                searchParamName = lastSearchParamName;
477
478                        } else {
479
480                                if (compartmentName.equalsIgnoreCase(theResourceName)) {
481
482                                        searchParamName = "_id";
483
484                                } else if (theAreCompartments) {
485
486                                        searchParamName =
487                                                        selectBestSearchParameterForCompartment(theRequestDetails, theResDef, compartmentName);
488                                }
489
490                                lastCompartmentName = compartmentName;
491                                lastSearchParamName = searchParamName;
492                        }
493
494                        if (searchParamName != null) {
495                                if (retVal == null) {
496                                        retVal = MultimapBuilder.hashKeys().arrayListValues().build();
497                                }
498                                retVal.put(searchParamName, nextCompartment);
499                        }
500                }
501
502                return retVal;
503        }
504
505        @Nullable
506        private ListMultimap<String, String> processAllowedCodes(
507                        RuntimeResourceDefinition theResDef, List<AllowedCodeInValueSet> theAllowedCodeInValueSet) {
508                ListMultimap<String, String> retVal = null;
509
510                for (AllowedCodeInValueSet next : theAllowedCodeInValueSet) {
511                        String resourceName = next.getResourceName();
512                        String valueSetUrl = next.getValueSetUrl();
513
514                        ValidateUtil.isNotBlankOrThrowIllegalArgument(
515                                        resourceName, "Resource name supplied by SearchNarrowingInterceptor must not be null");
516                        ValidateUtil.isNotBlankOrThrowIllegalArgument(
517                                        valueSetUrl, "ValueSet URL supplied by SearchNarrowingInterceptor must not be null");
518
519                        if (!resourceName.equals(theResDef.getName())) {
520                                continue;
521                        }
522
523                        if (shouldHandleThroughConsentService(valueSetUrl)) {
524                                continue;
525                        }
526
527                        String paramName;
528                        if (next.isNegate()) {
529                                paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_NOT_IN;
530                        } else {
531                                paramName = next.getSearchParameterName() + Constants.PARAMQUALIFIER_TOKEN_IN;
532                        }
533
534                        if (retVal == null) {
535                                retVal = MultimapBuilder.hashKeys().arrayListValues().build();
536                        }
537                        retVal.put(paramName, valueSetUrl);
538                }
539
540                return retVal;
541        }
542
543        /**
544         * For a given ValueSet URL, expand the valueset and check if the number of
545         * codes present is larger than the post filter threshold.
546         */
547        private boolean shouldHandleThroughConsentService(String theValueSetUrl) {
548                if (myValidationSupport != null && myPostFilterLargeValueSetThreshold != -1) {
549                        ValidationSupportContext ctx = new ValidationSupportContext(myValidationSupport);
550                        ValueSetExpansionOptions options = new ValueSetExpansionOptions();
551                        options.setCount(myPostFilterLargeValueSetThreshold);
552                        options.setIncludeHierarchy(false);
553                        IValidationSupport.ValueSetExpansionOutcome outcome =
554                                        myValidationSupport.expandValueSet(ctx, options, theValueSetUrl);
555                        if (outcome != null && outcome.getValueSet() != null) {
556                                FhirTerser terser = myValidationSupport.getFhirContext().newTerser();
557                                List<IBase> contains = terser.getValues(outcome.getValueSet(), "ValueSet.expansion.contains");
558                                int codeCount = contains.size();
559                                return codeCount >= myPostFilterLargeValueSetThreshold;
560                        }
561                }
562                return false;
563        }
564
565        private String selectBestSearchParameterForCompartment(
566                        RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, String compartmentName) {
567                String searchParamName = null;
568
569                Set<String> queryParameters = theRequestDetails.getParameters().keySet();
570
571                List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName);
572                if (!searchParams.isEmpty()) {
573
574                        // Resources like Observation have several fields that add the resource to
575                        // the compartment. In the case of Observation, it's subject, patient and performer.
576                        // For this kind of thing, we'll prefer the one that matches the compartment name.
577                        Optional<RuntimeSearchParam> primarySearchParam = searchParams.stream()
578                                        .filter(t -> t.getName().equalsIgnoreCase(compartmentName))
579                                        .findFirst();
580
581                        if (primarySearchParam.isPresent()) {
582                                String primarySearchParamName = primarySearchParam.get().getName();
583                                // If the primary search parameter is actually in use in the query, use it.
584                                if (queryParameters.contains(primarySearchParamName)) {
585                                        searchParamName = primarySearchParamName;
586                                } else {
587                                        // If the primary search parameter itself isn't in use, check to see whether any of its synonyms
588                                        // are.
589                                        Optional<RuntimeSearchParam> synonymInUse =
590                                                        findSynonyms(searchParams, primarySearchParam.get()).stream()
591                                                                        .filter(t -> queryParameters.contains(t.getName()))
592                                                                        .findFirst();
593                                        if (synonymInUse.isPresent()) {
594                                                // if a synonym is in use, use it
595                                                searchParamName = synonymInUse.get().getName();
596                                        } else {
597                                                // if not, i.e., the original query is not filtering on this field at all, use the primary
598                                                // search param
599                                                searchParamName = primarySearchParamName;
600                                        }
601                                }
602                        } else {
603                                // Otherwise, fall back to whatever search parameter is available
604                                searchParamName = searchParams.get(0).getName();
605                        }
606                }
607                return searchParamName;
608        }
609
610        private List<RuntimeSearchParam> findSynonyms(
611                        List<RuntimeSearchParam> searchParams, RuntimeSearchParam primarySearchParam) {
612                // We define two search parameters in a compartment as synonyms if they refer to the same field in the model,
613                // ignoring any qualifiers
614
615                String primaryBasePath = getBasePath(primarySearchParam);
616
617                return searchParams.stream()
618                                .filter(t -> primaryBasePath.equals(getBasePath(t)))
619                                .collect(Collectors.toList());
620        }
621
622        private String getBasePath(RuntimeSearchParam searchParam) {
623                int qualifierIndex = searchParam.getPath().indexOf(".where");
624                if (qualifierIndex == -1) {
625                        return searchParam.getPath();
626                } else {
627                        return searchParam.getPath().substring(0, qualifierIndex);
628                }
629        }
630
631        @Nonnull
632        private static Map<String, String[]> applyCompartmentParameters(
633                        @Nonnull ListMultimap<String, String> theParameterToOrValues,
634                        boolean thePatientIdMode,
635                        Map<String, String[]> theInputParameters) {
636                Map<String, String[]> newParameters = new HashMap<>(theInputParameters);
637                for (String nextParamName : theParameterToOrValues.keySet()) {
638                        List<String> nextAllowedValues = theParameterToOrValues.get(nextParamName);
639
640                        if (!newParameters.containsKey(nextParamName)) {
641
642                                /*
643                                 * If we don't already have a parameter of the given type, add one
644                                 */
645                                String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
646                                String[] paramValues = {nextValuesJoined};
647                                newParameters.put(nextParamName, paramValues);
648
649                        } else {
650
651                                /*
652                                 * If the client explicitly requested the given parameter already, we'll
653                                 * just update the request to have the intersection of the values that the client
654                                 * requested, and the values that the user is allowed to see
655                                 */
656                                String[] existingValues = newParameters.get(nextParamName);
657
658                                if (thePatientIdMode) {
659                                        List<String> nextAllowedValueIds = nextAllowedValues.stream()
660                                                        .map(t -> t.lastIndexOf("/") > -1 ? t.substring(t.lastIndexOf("/") + 1) : t)
661                                                        .collect(Collectors.toList());
662                                        boolean restrictedExistingList = false;
663                                        for (int i = 0; i < existingValues.length; i++) {
664
665                                                String nextExistingValue = existingValues[i];
666                                                List<String> nextRequestedValues =
667                                                                QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue);
668                                                List<String> nextPermittedValues = ListUtils.union(
669                                                                ListUtils.intersection(nextRequestedValues, nextAllowedValues),
670                                                                ListUtils.intersection(nextRequestedValues, nextAllowedValueIds));
671                                                if (!nextPermittedValues.isEmpty()) {
672                                                        restrictedExistingList = true;
673                                                        existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues);
674                                                }
675                                        }
676
677                                        /*
678                                         * If none of the values that were requested by the client overlap at all
679                                         * with the values that the user is allowed to see, the client shouldn't
680                                         * get *any* results back. We return an error code indicating that the
681                                         * caller is forbidden from accessing the resources they requested.
682                                         */
683                                        if (!restrictedExistingList) {
684                                                throw new ForbiddenOperationException(Msg.code(2026) + "Value not permitted for parameter "
685                                                                + UrlUtil.escapeUrlParam(nextParamName));
686                                        }
687
688                                } else {
689
690                                        int existingValuesCount = existingValues.length;
691                                        String[] newValues = Arrays.copyOf(existingValues, existingValuesCount + nextAllowedValues.size());
692                                        for (int i = 0; i < nextAllowedValues.size(); i++) {
693                                                newValues[existingValuesCount + i] = nextAllowedValues.get(i);
694                                        }
695                                        newParameters.put(nextParamName, newValues);
696                                }
697                        }
698                }
699                return newParameters;
700        }
701
702        static List<AllowedCodeInValueSet> getPostFilteringList(RequestDetails theRequestDetails) {
703                List<AllowedCodeInValueSet> retVal = getPostFilteringListOrNull(theRequestDetails);
704                if (retVal == null) {
705                        retVal = new ArrayList<>();
706                        theRequestDetails.setAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME, retVal);
707                }
708                return retVal;
709        }
710
711        @SuppressWarnings("unchecked")
712        static List<AllowedCodeInValueSet> getPostFilteringListOrNull(RequestDetails theRequestDetails) {
713                return (List<AllowedCodeInValueSet>) theRequestDetails.getAttribute(POST_FILTERING_LIST_ATTRIBUTE_NAME);
714        }
715
716        private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> {
717                private final FhirContext myFhirContext;
718                private final ServletRequestDetails myRequestDetails;
719                private final AuthorizedList myAuthorizedList;
720
721                public BundleEntryUrlProcessor(FhirContext theFhirContext, ServletRequestDetails theRequestDetails) {
722                        myFhirContext = theFhirContext;
723                        myRequestDetails = theRequestDetails;
724                        myAuthorizedList = buildAuthorizedList(theRequestDetails);
725                }
726
727                @SuppressWarnings("EnumSwitchStatementWhichMissesCases")
728                @Override
729                public void accept(ModifiableBundleEntry theModifiableBundleEntry) {
730                        if (myAuthorizedList == null) {
731                                return;
732                        }
733
734                        RequestTypeEnum method = theModifiableBundleEntry.getRequestMethod();
735                        String requestUrl = theModifiableBundleEntry.getRequestUrl();
736                        if (method != null && isNotBlank(requestUrl)) {
737
738                                String resourceType = UrlUtil.parseUrl(requestUrl).getResourceType();
739
740                                switch (method) {
741                                        case GET: {
742                                                String existingRequestUrl = theModifiableBundleEntry.getRequestUrl();
743                                                String newConditionalUrl = narrowConditionalUrl(
744                                                                myRequestDetails, existingRequestUrl, false, resourceType, true, myAuthorizedList);
745                                                if (isNotBlank(newConditionalUrl)) {
746                                                        newConditionalUrl = resourceType + "?" + newConditionalUrl;
747                                                        theModifiableBundleEntry.setRequestUrl(myFhirContext, newConditionalUrl);
748                                                }
749                                                break;
750                                        }
751                                        case POST: {
752                                                if (myNarrowConditionalUrls) {
753                                                        String existingConditionalUrl = theModifiableBundleEntry.getConditionalUrl();
754                                                        if (isNotBlank(existingConditionalUrl)) {
755                                                                String newConditionalUrl = narrowConditionalUrl(
756                                                                                myRequestDetails,
757                                                                                existingConditionalUrl,
758                                                                                true,
759                                                                                resourceType,
760                                                                                false,
761                                                                                myAuthorizedList);
762                                                                if (isNotBlank(newConditionalUrl)) {
763                                                                        theModifiableBundleEntry.setRequestIfNoneExist(myFhirContext, newConditionalUrl);
764                                                                }
765                                                        }
766                                                }
767                                                break;
768                                        }
769                                        case PUT:
770                                        case DELETE:
771                                        case PATCH: {
772                                                if (myNarrowConditionalUrls) {
773                                                        String existingConditionalUrl = theModifiableBundleEntry.getConditionalUrl();
774                                                        if (isNotBlank(existingConditionalUrl)) {
775                                                                String newConditionalUrl = narrowConditionalUrl(
776                                                                                myRequestDetails,
777                                                                                existingConditionalUrl,
778                                                                                true,
779                                                                                resourceType,
780                                                                                false,
781                                                                                myAuthorizedList);
782                                                                if (isNotBlank(newConditionalUrl)) {
783                                                                        theModifiableBundleEntry.setRequestUrl(myFhirContext, newConditionalUrl);
784                                                                }
785                                                        }
786                                                }
787                                                break;
788                                        }
789                                }
790                        }
791                }
792        }
793}