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.rest.api.QualifiedParamList;
027import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
028import ca.uhn.fhir.rest.api.server.RequestDetails;
029import ca.uhn.fhir.rest.param.ParameterUtil;
030import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
031import ca.uhn.fhir.rest.server.interceptor.InterceptorAdapter;
032import org.apache.commons.collections4.ListUtils;
033import org.apache.commons.lang3.StringUtils;
034import org.apache.commons.lang3.Validate;
035
036import javax.servlet.http.HttpServletRequest;
037import javax.servlet.http.HttpServletResponse;
038import java.util.*;
039
040/**
041 * This interceptor can be used to automatically narrow the scope of searches in order to
042 * automatically restrict the searches to specific compartments.
043 * <p>
044 * For example, this interceptor
045 * could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data
046 * in the <code>Patient/123</code> compartment). In this case, a user performing a search
047 * for<br/>
048 * <code>http://baseurl/Observation?category=laboratory</code><br/>
049 * would receive results as though they had requested<br/>
050 * <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code>
051 * </p>
052 * <p>
053 * Note that this interceptor should be used in combination with {@link AuthorizationInterceptor}
054 * if you are restricting results because of a security restriction. This interceptor is not
055 * intended to be a failsafe way of preventing users from seeing the wrong data (that is the
056 * purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to
057 * help users simplify their queries while not receiving security errors for to trying to access
058 * data they do not have access to see.
059 * </p>
060 *
061 * @see AuthorizationInterceptor
062 */
063public abstract class SearchNarrowingInterceptor extends InterceptorAdapter {
064
065        /**
066         * Subclasses should override this method to supply the set of compartments that
067         * the user making the request should actually have access to.
068         * <p>
069         * Typically this is done by examining <code>theRequestDetails</code> to find
070         * out who the current user is and then building a list of Strings.
071         * </p>
072         *
073         * @param theRequestDetails The individual request currently being applied
074         * @return The list of allowed compartments and instances that should be used
075         * for search narrowing. If this method returns <code>null</code>, no narrowing will
076         * be performed
077         */
078        protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) {
079                return null;
080        }
081
082
083        @Override
084        public boolean incomingRequestPostProcessed(RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException {
085
086                // We don't support this operation type yet
087                Validate.isTrue(theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_SYSTEM);
088
089                if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE) {
090                        return true;
091                }
092
093                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
094                RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName());
095                HashMap<String, List<String>> parameterToOrValues = new HashMap<>();
096                AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
097                if (authorizedList == null) {
098                        return true;
099                }
100
101                /*
102                 * Create a map of search parameter values that need to be added to the
103                 * given request
104                 */
105                Collection<String> compartments = authorizedList.getAllowedCompartments();
106                if (compartments != null) {
107                        processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, compartments, true);
108                }
109                Collection<String> resources = authorizedList.getAllowedInstances();
110                if (resources != null) {
111                        processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, resources, false);
112                }
113
114                /*
115                 * Add any param values to the actual request
116                 */
117                if (parameterToOrValues.size() > 0) {
118                        Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters());
119                        for (Map.Entry<String, List<String>> nextEntry : parameterToOrValues.entrySet()) {
120                                String nextParamName = nextEntry.getKey();
121                                List<String> nextAllowedValues = nextEntry.getValue();
122
123                                if (!newParameters.containsKey(nextParamName)) {
124
125                                        /*
126                                         * If we don't already have a parameter of the given type, add one
127                                         */
128                                        String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
129                                        String[] paramValues = {nextValuesJoined};
130                                        newParameters.put(nextParamName, paramValues);
131
132                                } else {
133
134                                        /*
135                                         * If the client explicitly requested the given parameter already, we'll
136                                         * just update the request to have the intersection of the values that the client
137                                         * requested, and the values that the user is allowed to see
138                                         */
139                                        String[] existingValues = newParameters.get(nextParamName);
140                                        boolean restrictedExistingList = false;
141                                        for (int i = 0; i < existingValues.length; i++) {
142
143                                                String nextExistingValue = existingValues[i];
144                                                List<String> nextRequestedValues = QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue);
145                                                List<String> nextPermittedValues = ListUtils.intersection(nextRequestedValues, nextAllowedValues);
146                                                if (nextPermittedValues.size() > 0) {
147                                                        restrictedExistingList = true;
148                                                        existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues);
149                                                }
150
151                                        }
152
153                                        /*
154                                         * If none of the values that were requested by the client overlap at all
155                                         * with the values that the user is allowed to see, we'll just add the permitted
156                                         * list as a new list. Ultimately this scenario actually means that the client
157                                         * shouldn't get *any* results back, and adding a new AND parameter (that doesn't
158                                         * overlap at all with the others) is one way of ensuring that.
159                                         */
160                                        if (!restrictedExistingList) {
161                                                String[] newValues = Arrays.copyOf(existingValues, existingValues.length + 1);
162                                                newValues[existingValues.length] = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
163                                                newParameters.put(nextParamName, newValues);
164                                        }
165                                }
166
167                        }
168                        theRequestDetails.setParameters(newParameters);
169                }
170
171                return true;
172        }
173
174        private void processResourcesOrCompartments(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, HashMap<String, List<String>> theParameterToOrValues, Collection<String> theResourcesOrCompartments, boolean theAreCompartments) {
175                String lastCompartmentName = null;
176                String lastSearchParamName = null;
177                for (String nextCompartment : theResourcesOrCompartments) {
178                        Validate.isTrue(StringUtils.countMatches(nextCompartment, '/') == 1, "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", nextCompartment);
179                        String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/'));
180
181                        String searchParamName = null;
182                        if (compartmentName.equalsIgnoreCase(lastCompartmentName)) {
183
184                                // Avoid doing a lookup for the same thing repeatedly
185                                searchParamName = lastSearchParamName;
186
187                        } else {
188
189                                if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) {
190
191                                        searchParamName = "_id";
192
193                                } else if (theAreCompartments) {
194
195                                        List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName);
196                                        if (searchParams.size() > 0) {
197
198                                                // Resources like Observation have several fields that add the resource to
199                                                // the compartment. In the case of Observation, it's subject, patient and performer.
200                                                // For this kind of thing, we'll prefer the one called "patient".
201                                                RuntimeSearchParam searchParam =
202                                                        searchParams
203                                                                .stream()
204                                                                .filter(t -> t.getName().equalsIgnoreCase(compartmentName))
205                                                                .findFirst()
206                                                                .orElse(searchParams.get(0));
207                                                searchParamName = searchParam.getName();
208
209                                        }
210                                }
211
212                                lastCompartmentName = compartmentName;
213                                lastSearchParamName = searchParamName;
214
215                        }
216
217                        if (searchParamName != null) {
218                                List<String> orValues = theParameterToOrValues.computeIfAbsent(searchParamName, t -> new ArrayList<>());
219                                orValues.add(nextCompartment);
220                        }
221                }
222        }
223
224}