001package ca.uhn.fhir.rest.server.interceptor.auth;
002
003/*-
004 * #%L
005 * HAPI FHIR - Server Framework
006 * %%
007 * Copyright (C) 2014 - 2019 University Health Network
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 *
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 *
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.context.RuntimeResourceDefinition;
025import ca.uhn.fhir.context.RuntimeSearchParam;
026import ca.uhn.fhir.interceptor.api.Hook;
027import ca.uhn.fhir.interceptor.api.Pointcut;
028import ca.uhn.fhir.rest.api.QualifiedParamList;
029import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
030import ca.uhn.fhir.rest.api.server.RequestDetails;
031import ca.uhn.fhir.rest.param.ParameterUtil;
032import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
033import ca.uhn.fhir.rest.server.method.BaseMethodBinding;
034import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
035import ca.uhn.fhir.rest.server.servlet.ServletSubRequestDetails;
036import ca.uhn.fhir.rest.server.util.ServletRequestUtil;
037import ca.uhn.fhir.util.BundleUtil;
038import ca.uhn.fhir.util.bundle.ModifiableBundleEntry;
039import com.google.common.collect.ArrayListMultimap;
040import org.apache.commons.collections4.ListUtils;
041import org.apache.commons.lang3.StringUtils;
042import org.apache.commons.lang3.Validate;
043import org.hl7.fhir.instance.model.api.IBaseBundle;
044import org.slf4j.Logger;
045import org.slf4j.LoggerFactory;
046
047import javax.servlet.http.HttpServletRequest;
048import javax.servlet.http.HttpServletResponse;
049import java.util.*;
050import java.util.function.Consumer;
051
052/**
053 * This interceptor can be used to automatically narrow the scope of searches in order to
054 * automatically restrict the searches to specific compartments.
055 * <p>
056 * For example, this interceptor
057 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data
058 * in the <code>Patient/123</code> compartment). In this case, a user performing a search
059 * for<br/>
060 * <code>http://baseurl/Observation?category=laboratory</code><br/>
061 * would receive results as though they had requested<br/>
062 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code>
063 * </p>
064 * <p>
065 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor}
066 * if you are restricting results because of a security restriction. This interceptor is not
067 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the
068 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to
069 * help users simplify their queries while not receiving security errors for to trying to access
070 * data they do not have access to see.
071 * </p>
072 *
073 * @see AuthorizationInterceptor
074 */
075public class SearchNarrowingInterceptor {
076        private static final Logger ourLog = LoggerFactory.getLogger(SearchNarrowingInterceptor.class);
077
078
079        /**
080         * Subclasses should override this method to supply the set of compartments that
081         * the user making the request should actually have access to.
082         * <p>
083         * Typically this is done by examining <code>theRequestDetails</code> to find
084         * out who the current user is and then building a list of Strings.
085         * </p>
086         *
087         * @param theRequestDetails The individual request currently being applied
088         * @return The list of allowed compartments and instances that should be used
089         * for search narrowing. If this method returns <code>null</code>, no narrowing will
090         * be performed
091         */
092        protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) {
093                return null;
094        }
095
096        @Hook(Pointcut.SERVER_INCOMING_REQUEST_POST_PROCESSED)
097        public boolean incomingRequestPostProcessed(RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException {
098                // We don't support this operation type yet
099                Validate.isTrue(theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_SYSTEM);
100
101                if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE) {
102                        return true;
103                }
104
105                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
106                RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName());
107                HashMap<String, List<String>> parameterToOrValues = new HashMap<>();
108                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
109                if (authorizedList == null) {
110                        return true;
111                }
112
113                /*
114                 * Create a map of search parameter values that need to be added to the
115                 * given request
116                 */
117                Collection<String> compartments = authorizedList.getAllowedCompartments();
118                if (compartments != null) {
119                        processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, compartments, true);
120                }
121                Collection<String> resources = authorizedList.getAllowedInstances();
122                if (resources != null) {
123                        processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, resources, false);
124                }
125
126                /*
127                 * Add any param values to the actual request
128                 */
129                if (parameterToOrValues.size() > 0) {
130                        Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters());
131                        for (Map.Entry<String, List<String>> nextEntry : parameterToOrValues.entrySet()) {
132                                String nextParamName = nextEntry.getKey();
133                                List<String> nextAllowedValues = nextEntry.getValue();
134
135                                if (!newParameters.containsKey(nextParamName)) {
136
137                                        /*
138                                         * If we don't already have a parameter of the given type, add one
139                                         */
140                                        String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
141                                        String[] paramValues = {nextValuesJoined};
142                                        newParameters.put(nextParamName, paramValues);
143
144                                } else {
145
146                                        /*
147                                         * If the client explicitly requested the given parameter already, we'll
148                                         * just update the request to have the intersection of the values that the client
149                                         * requested, and the values that the user is allowed to see
150                                         */
151                                        String[] existingValues = newParameters.get(nextParamName);
152                                        boolean restrictedExistingList = false;
153                                        for (int i = 0; i < existingValues.length; i++) {
154
155                                                String nextExistingValue = existingValues[i];
156                                                List<String> nextRequestedValues = QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue);
157                                                List<String> nextPermittedValues = ListUtils.intersection(nextRequestedValues, nextAllowedValues);
158                                                if (nextPermittedValues.size() > 0) {
159                                                        restrictedExistingList = true;
160                                                        existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues);
161                                                }
162
163                                        }
164
165                                        /*
166                                         * If none of the values that were requested by the client overlap at all
167                                         * with the values that the user is allowed to see, we'll just add the permitted
168                                         * list as a new list. Ultimately this scenario actually means that the client
169                                         * shouldn't get *any* results back, and adding a new AND parameter (that doesn't
170                                         * overlap at all with the others) is one way of ensuring that.
171                                         */
172                                        if (!restrictedExistingList) {
173                                                String[] newValues = Arrays.copyOf(existingValues, existingValues.length + 1);
174                                                newValues[existingValues.length] = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
175                                                newParameters.put(nextParamName, newValues);
176                                        }
177                                }
178
179                        }
180                        theRequestDetails.setParameters(newParameters);
181                }
182
183                return true;
184        }
185
186        @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
187        public void incomingRequestPreHandled(ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException {
188                if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.TRANSACTION) {
189                        return;
190                }
191
192                IBaseBundle bundle = (IBaseBundle) theRequestDetails.getResource();
193                FhirContext ctx = theRequestDetails.getFhirContext();
194                BundleEntryUrlProcessor processor = new BundleEntryUrlProcessor(ctx, theRequestDetails, theRequest, theResponse);
195                BundleUtil.processEntries(ctx, bundle, processor);
196        }
197
198        private class BundleEntryUrlProcessor implements Consumer<ModifiableBundleEntry> {
199                private final FhirContext myFhirContext;
200                private final ServletRequestDetails myRequestDetails;
201                private final HttpServletRequest myRequest;
202                private final HttpServletResponse myResponse;
203
204                public BundleEntryUrlProcessor(FhirContext theFhirContext, ServletRequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) {
205                        myFhirContext = theFhirContext;
206                        myRequestDetails = theRequestDetails;
207                        myRequest = theRequest;
208                        myResponse = theResponse;
209                }
210
211                @Override
212                public void accept(ModifiableBundleEntry theModifiableBundleEntry) {
213                        ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create();
214
215                        String url = theModifiableBundleEntry.getRequestUrl();
216
217                        ServletSubRequestDetails subServletRequestDetails = ServletRequestUtil.getServletSubRequestDetails(myRequestDetails, url, paramValues);
218                        BaseMethodBinding<?> method = subServletRequestDetails.getServer().determineResourceMethod(subServletRequestDetails, url);
219                        RestOperationTypeEnum restOperationType = method.getRestOperationType();
220                        subServletRequestDetails.setRestOperationType(restOperationType);
221
222                        incomingRequestPostProcessed(subServletRequestDetails, myRequest, myResponse);
223
224                        theModifiableBundleEntry.setRequestUrl(myFhirContext, ServletRequestUtil.extractUrl(subServletRequestDetails));
225                }
226        }
227
228        private void processResourcesOrCompartments(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, HashMap<String, List<String>> theParameterToOrValues, Collection<String> theResourcesOrCompartments, boolean theAreCompartments) {
229                String lastCompartmentName = null;
230                String lastSearchParamName = null;
231                for (String nextCompartment : theResourcesOrCompartments) {
232                        Validate.isTrue(StringUtils.countMatches(nextCompartment, '/') == 1, "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", nextCompartment);
233                        String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/'));
234
235                        String searchParamName = null;
236                        if (compartmentName.equalsIgnoreCase(lastCompartmentName)) {
237
238                                // Avoid doing a lookup for the same thing repeatedly
239                                searchParamName = lastSearchParamName;
240
241                        } else {
242
243                                if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) {
244
245                                        searchParamName = "_id";
246
247                                } else if (theAreCompartments) {
248
249                                        List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName);
250                                        if (searchParams.size() > 0) {
251
252                                                // Resources like Observation have several fields that add the resource to
253                                                // the compartment. In the case of Observation, it's subject, patient and performer.
254                                                // For this kind of thing, we'll prefer the one called "patient".
255                                                RuntimeSearchParam searchParam =
256                                                        searchParams
257                                                                .stream()
258                                                                .filter(t -> t.getName().equalsIgnoreCase(compartmentName))
259                                                                .findFirst()
260                                                                .orElse(searchParams.get(0));
261                                                searchParamName = searchParam.getName();
262
263                                        }
264                                }
265
266                                lastCompartmentName = compartmentName;
267                                lastSearchParamName = searchParamName;
268
269                        }
270
271                        if (searchParamName != null) {
272                                List<String> orValues = theParameterToOrValues.computeIfAbsent(searchParamName, t -> new ArrayList<>());
273                                orValues.add(nextCompartment);
274                        }
275                }
276        }
277
278}