001package ca.uhn.fhir.rest.server.interceptor.consent;
002
003/*-
004 * #%L
005 * HAPI FHIR - Server Framework
006 * %%
007 * Copyright (C) 2014 - 2022 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.BaseRuntimeChildDefinition;
024import ca.uhn.fhir.context.BaseRuntimeElementDefinition;
025import ca.uhn.fhir.context.FhirContext;
026import ca.uhn.fhir.i18n.Msg;
027import ca.uhn.fhir.interceptor.api.Hook;
028import ca.uhn.fhir.interceptor.api.Interceptor;
029import ca.uhn.fhir.interceptor.api.Pointcut;
030import ca.uhn.fhir.rest.api.Constants;
031import ca.uhn.fhir.rest.api.server.IPreResourceAccessDetails;
032import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
033import ca.uhn.fhir.rest.api.server.RequestDetails;
034import ca.uhn.fhir.rest.api.server.ResponseDetails;
035import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
036import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
037import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
038import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationConstants;
039import ca.uhn.fhir.rest.server.util.ICachedSearchDetails;
040import ca.uhn.fhir.util.BundleUtil;
041import ca.uhn.fhir.util.IModelVisitor2;
042import org.apache.commons.lang3.Validate;
043import org.hl7.fhir.instance.model.api.IBase;
044import org.hl7.fhir.instance.model.api.IBaseBundle;
045import org.hl7.fhir.instance.model.api.IBaseExtension;
046import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
047import org.hl7.fhir.instance.model.api.IBaseResource;
048
049import java.util.ArrayList;
050import java.util.Arrays;
051import java.util.Collections;
052import java.util.IdentityHashMap;
053import java.util.List;
054import java.util.Map;
055import java.util.concurrent.atomic.AtomicInteger;
056import java.util.stream.Collectors;
057
058import static ca.uhn.fhir.rest.api.Constants.URL_TOKEN_METADATA;
059import static ca.uhn.fhir.rest.server.provider.ProviderConstants.OPERATION_META;
060
061/**
062 * The ConsentInterceptor can be used to apply arbitrary consent rules and data access policies
063 * on responses from a FHIR server.
064 * <p>
065 * See <a href="https://hapifhir.io/hapi-fhir/docs/security/consent_interceptor.html">Consent Interceptor</a> for
066 * more information on this interceptor.
067 * </p>
068 */
069@Interceptor(order = AuthorizationConstants.ORDER_CONSENT_INTERCEPTOR)
070public class ConsentInterceptor {
071        private static final AtomicInteger ourInstanceCount = new AtomicInteger(0);
072        private final int myInstanceIndex = ourInstanceCount.incrementAndGet();
073        private final String myRequestAuthorizedKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_AUTHORIZED";
074        private final String myRequestCompletedKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_COMPLETED";
075        private final String myRequestSeenResourcesKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_SEENRESOURCES";
076
077        private volatile List<IConsentService> myConsentService = Collections.emptyList();
078        private IConsentContextServices myContextConsentServices = IConsentContextServices.NULL_IMPL;
079
080        /**
081         * Constructor
082         */
083        public ConsentInterceptor() {
084                super();
085        }
086
087        /**
088         * Constructor
089         *
090         * @param theConsentService Must not be <code>null</code>
091         */
092        public ConsentInterceptor(IConsentService theConsentService) {
093                this(theConsentService, IConsentContextServices.NULL_IMPL);
094        }
095
096        /**
097         * Constructor
098         *
099         * @param theConsentService         Must not be <code>null</code>
100         * @param theContextConsentServices Must not be <code>null</code>
101         */
102        public ConsentInterceptor(IConsentService theConsentService, IConsentContextServices theContextConsentServices) {
103                setConsentService(theConsentService);
104                setContextConsentServices(theContextConsentServices);
105        }
106
107        public void setContextConsentServices(IConsentContextServices theContextConsentServices) {
108                Validate.notNull(theContextConsentServices, "theContextConsentServices must not be null");
109                myContextConsentServices = theContextConsentServices;
110        }
111
112        /**
113         * @deprecated Use {@link #registerConsentService(IConsentService)} instead
114         */
115        @Deprecated
116        public void setConsentService(IConsentService theConsentService) {
117                Validate.notNull(theConsentService, "theConsentService must not be null");
118                myConsentService = Collections.singletonList(theConsentService);
119        }
120
121        /**
122         * Adds a consent service to the chain.
123         * <p>
124         * Thread safety note: This method can be called while the service is actively processing requestes
125         *
126         * @param theConsentService The service to register. Must not be <code>null</code>.
127         * @since 6.0.0
128         */
129        public ConsentInterceptor registerConsentService(IConsentService theConsentService) {
130                Validate.notNull(theConsentService, "theConsentService must not be null");
131                List<IConsentService> newList = new ArrayList<>(myConsentService.size() + 1);
132                newList.addAll(myConsentService);
133                newList.add(theConsentService);
134                myConsentService = newList;
135                return this;
136        }
137
138        /**
139         * Removes a consent service from the chain.
140         * <p>
141         * Thread safety note: This method can be called while the service is actively processing requestes
142         *
143         * @param theConsentService The service to unregister. Must not be <code>null</code>.
144         * @since 6.0.0
145         */
146        public ConsentInterceptor unregisterConsentService(IConsentService theConsentService) {
147                Validate.notNull(theConsentService, "theConsentService must not be null");
148                List<IConsentService> newList = myConsentService
149                        .stream()
150                        .filter(t -> t != theConsentService)
151                        .collect(Collectors.toList());
152                myConsentService = newList;
153                return this;
154        }
155
156        @Hook(value = Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
157        public void interceptPreHandled(RequestDetails theRequestDetails) {
158                if (isSkipServiceForRequest(theRequestDetails)) {
159                        return;
160                }
161
162                validateParameter(theRequestDetails.getParameters());
163
164                for (IConsentService nextService : myConsentService) {
165                        ConsentOutcome outcome = nextService.startOperation(theRequestDetails, myContextConsentServices);
166                        Validate.notNull(outcome, "Consent service returned null outcome");
167
168                        switch (outcome.getStatus()) {
169                                case REJECT:
170                                        throw toForbiddenOperationException(outcome);
171                                case PROCEED:
172                                        continue;
173                                case AUTHORIZED:
174                                        Map<Object, Object> userData = theRequestDetails.getUserData();
175                                        userData.put(myRequestAuthorizedKey, Boolean.TRUE);
176                                        return;
177                        }
178                }
179        }
180
181        @Hook(value = Pointcut.STORAGE_PRECHECK_FOR_CACHED_SEARCH)
182        public boolean interceptPreCheckForCachedSearch(RequestDetails theRequestDetails) {
183                if (isRequestAuthorized(theRequestDetails)) {
184                        return true;
185                }
186                return false;
187        }
188
189        @Hook(value = Pointcut.STORAGE_PRESEARCH_REGISTERED)
190        public void interceptPreSearchRegistered(RequestDetails theRequestDetails, ICachedSearchDetails theCachedSearchDetails) {
191                if (!isRequestAuthorized(theRequestDetails)) {
192                        theCachedSearchDetails.setCannotBeReused();
193                }
194        }
195
196        @Hook(value = Pointcut.STORAGE_PREACCESS_RESOURCES)
197        public void interceptPreAccess(RequestDetails theRequestDetails, IPreResourceAccessDetails thePreResourceAccessDetails) {
198                if (isRequestAuthorized(theRequestDetails)) {
199                        return;
200                }
201                if (isSkipServiceForRequest(theRequestDetails)) {
202                        return;
203                }
204                if (myConsentService.isEmpty()) {
205                        return;
206                }
207
208                // First check if we should be calling canSeeResource for the individual
209                // consent services
210                boolean[] processConsentSvcs = new boolean[myConsentService.size()];
211                boolean processAnyConsentSvcs = false;
212                for (int consentSvcIdx = 0; consentSvcIdx < myConsentService.size(); consentSvcIdx++) {
213                        IConsentService nextService = myConsentService.get(consentSvcIdx);
214
215                        boolean shouldCallCanSeeResource = nextService.shouldProcessCanSeeResource(theRequestDetails, myContextConsentServices);
216                        processAnyConsentSvcs |= shouldCallCanSeeResource;
217                        processConsentSvcs[consentSvcIdx] = shouldCallCanSeeResource;
218                }
219
220                if (!processAnyConsentSvcs) {
221                        return;
222                }
223
224                IdentityHashMap<IBaseResource, Boolean> authorizedResources = getAuthorizedResourcesMap(theRequestDetails);
225                for (int resourceIdx = 0; resourceIdx < thePreResourceAccessDetails.size(); resourceIdx++) {
226                        IBaseResource nextResource = thePreResourceAccessDetails.getResource(resourceIdx);
227                        for (int consentSvcIdx = 0; consentSvcIdx < myConsentService.size(); consentSvcIdx++) {
228                                IConsentService nextService = myConsentService.get(consentSvcIdx);
229
230                                if (!processConsentSvcs[consentSvcIdx]) {
231                                        continue;
232                                }
233
234                                ConsentOutcome outcome = nextService.canSeeResource(theRequestDetails, nextResource, myContextConsentServices);
235                                Validate.notNull(outcome, "Consent service returned null outcome");
236                                Validate.isTrue(outcome.getResource() == null, "Consent service returned a resource in its outcome. This is not permitted in canSeeResource(..)");
237
238                                boolean skipSubsequentServices = false;
239                                switch (outcome.getStatus()) {
240                                        case PROCEED:
241                                                break;
242                                        case AUTHORIZED:
243                                                authorizedResources.put(nextResource, Boolean.TRUE);
244                                                skipSubsequentServices = true;
245                                                break;
246                                        case REJECT:
247                                                thePreResourceAccessDetails.setDontReturnResourceAtIndex(resourceIdx);
248                                                skipSubsequentServices = true;
249                                                break;
250                                }
251
252                                if (skipSubsequentServices) {
253                                        break;
254                                }
255                        }
256                }
257        }
258
259        @Hook(value = Pointcut.STORAGE_PRESHOW_RESOURCES)
260        public void interceptPreShow(RequestDetails theRequestDetails, IPreResourceShowDetails thePreResourceShowDetails) {
261                if (isRequestAuthorized(theRequestDetails)) {
262                        return;
263                }
264                if (isAllowListedRequest(theRequestDetails)) {
265                        return;
266                }
267                if (isSkipServiceForRequest(theRequestDetails)) {
268                        return;
269                }
270                if (myConsentService.isEmpty()) {
271                        return;
272                }
273
274                IdentityHashMap<IBaseResource, Boolean> authorizedResources = getAuthorizedResourcesMap(theRequestDetails);
275
276                for (int i = 0; i < thePreResourceShowDetails.size(); i++) {
277
278                        IBaseResource resource = thePreResourceShowDetails.getResource(i);
279                        if (resource == null || authorizedResources.putIfAbsent(resource, Boolean.TRUE) != null) {
280                                continue;
281                        }
282
283                        for (IConsentService nextService : myConsentService) {
284                                ConsentOutcome nextOutcome = nextService.willSeeResource(theRequestDetails, resource, myContextConsentServices);
285                                IBaseResource newResource = nextOutcome.getResource();
286
287                                switch (nextOutcome.getStatus()) {
288                                        case PROCEED:
289                                                if (newResource != null) {
290                                                        thePreResourceShowDetails.setResource(i, newResource);
291                                                        resource = newResource;
292                                                }
293                                                continue;
294                                        case AUTHORIZED:
295                                                if (newResource != null) {
296                                                        thePreResourceShowDetails.setResource(i, newResource);
297                                                }
298                                                continue;
299                                        case REJECT:
300                                                if (nextOutcome.getOperationOutcome() != null) {
301                                                        IBaseOperationOutcome newOperationOutcome = nextOutcome.getOperationOutcome();
302                                                        thePreResourceShowDetails.setResource(i, newOperationOutcome);
303                                                        authorizedResources.put(newOperationOutcome, true);
304                                                } else {
305                                                        resource = null;
306                                                        thePreResourceShowDetails.setResource(i, null);
307                                                }
308                                                continue;
309                                }
310                        }
311                }
312        }
313
314        @Hook(value = Pointcut.SERVER_OUTGOING_RESPONSE)
315        public void interceptOutgoingResponse(RequestDetails theRequestDetails, ResponseDetails theResource) {
316                if (theResource.getResponseResource() == null) {
317                        return;
318                }
319                if (isRequestAuthorized(theRequestDetails)) {
320                        return;
321                }
322                if (isAllowListedRequest(theRequestDetails)) {
323                        return;
324                }
325                if (isSkipServiceForRequest(theRequestDetails)) {
326                        return;
327                }
328                if (myConsentService.isEmpty()) {
329                        return;
330                }
331
332                IdentityHashMap<IBaseResource, Boolean> authorizedResources = getAuthorizedResourcesMap(theRequestDetails);
333
334                // See outer resource
335                if (authorizedResources.putIfAbsent(theResource.getResponseResource(), Boolean.TRUE) == null) {
336
337                        for (IConsentService next : myConsentService) {
338                                final ConsentOutcome outcome = next.willSeeResource(theRequestDetails, theResource.getResponseResource(), myContextConsentServices);
339                                if (outcome.getResource() != null) {
340                                        theResource.setResponseResource(outcome.getResource());
341                                }
342
343                                // Clear the total
344                                if (theResource.getResponseResource() instanceof IBaseBundle) {
345                                        BundleUtil.setTotal(theRequestDetails.getFhirContext(), (IBaseBundle) theResource.getResponseResource(), null);
346                                }
347
348                                switch (outcome.getStatus()) {
349                                        case REJECT:
350                                                if (outcome.getOperationOutcome() != null) {
351                                                        theResource.setResponseResource(outcome.getOperationOutcome());
352                                                } else {
353                                                        theResource.setResponseResource(null);
354                                                        theResource.setResponseCode(Constants.STATUS_HTTP_204_NO_CONTENT);
355                                                }
356                                                // Return immediately
357                                                return;
358                                        case AUTHORIZED:
359                                                // Don't check children, so return immediately
360                                                return;
361                                        case PROCEED:
362                                                // Check children, so proceed
363                                                break;
364                                }
365                        }
366                }
367
368                // See child resources
369                IBaseResource outerResource = theResource.getResponseResource();
370                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
371                IModelVisitor2 visitor = new IModelVisitor2() {
372                        @Override
373                        public boolean acceptElement(IBase theElement, List<IBase> theContainingElementPath, List<BaseRuntimeChildDefinition> theChildDefinitionPath, List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) {
374
375                                // Clear the total
376                                if (theElement instanceof IBaseBundle) {
377                                        BundleUtil.setTotal(theRequestDetails.getFhirContext(), (IBaseBundle) theElement, null);
378                                }
379
380                                if (theElement == outerResource) {
381                                        return true;
382                                }
383                                if (theElement instanceof IBaseResource) {
384                                        IBaseResource resource = (IBaseResource) theElement;
385                                        if (authorizedResources.putIfAbsent(resource, Boolean.TRUE) != null) {
386                                                return true;
387                                        }
388
389                                        boolean shouldCheckChildren = true;
390                                        for (IConsentService next : myConsentService) {
391                                                ConsentOutcome childOutcome = next.willSeeResource(theRequestDetails, resource, myContextConsentServices);
392
393                                                IBaseResource replacementResource = null;
394                                                boolean shouldReplaceResource = false;
395
396                                                switch (childOutcome.getStatus()) {
397                                                        case REJECT:
398                                                                replacementResource = childOutcome.getOperationOutcome();
399                                                                shouldReplaceResource = true;
400                                                                break;
401                                                        case PROCEED:
402                                                        case AUTHORIZED:
403                                                                replacementResource = childOutcome.getResource();
404                                                                shouldReplaceResource = replacementResource != null;
405                                                                shouldCheckChildren &= childOutcome.getStatus() == ConsentOperationStatusEnum.PROCEED;
406                                                                break;
407                                                }
408
409                                                if (shouldReplaceResource) {
410                                                        IBase container = theContainingElementPath.get(theContainingElementPath.size() - 2);
411                                                        BaseRuntimeChildDefinition containerChildElement = theChildDefinitionPath.get(theChildDefinitionPath.size() - 1);
412                                                        containerChildElement.getMutator().setValue(container, replacementResource);
413                                                        resource = replacementResource;
414                                                }
415
416                                        }
417
418                                        return shouldCheckChildren;
419                                }
420
421                                return true;
422                        }
423
424                        @Override
425                        public boolean acceptUndeclaredExtension(IBaseExtension<?, ?> theNextExt, List<IBase> theContainingElementPath, List<BaseRuntimeChildDefinition> theChildDefinitionPath, List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) {
426                                return true;
427                        }
428                };
429                ctx.newTerser().visit(outerResource, visitor);
430
431        }
432
433        private IdentityHashMap<IBaseResource, Boolean> getAuthorizedResourcesMap(RequestDetails theRequestDetails) {
434                return getAlreadySeenResourcesMap(theRequestDetails, myRequestSeenResourcesKey);
435        }
436
437        @Hook(value = Pointcut.SERVER_HANDLE_EXCEPTION)
438        public void requestFailed(RequestDetails theRequest, BaseServerResponseException theException) {
439                theRequest.getUserData().put(myRequestCompletedKey, Boolean.TRUE);
440                for (IConsentService next : myConsentService) {
441                        next.completeOperationFailure(theRequest, theException, myContextConsentServices);
442                }
443        }
444
445        @Hook(value = Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY)
446        public void requestSucceeded(RequestDetails theRequest) {
447                if (Boolean.TRUE.equals(theRequest.getUserData().get(myRequestCompletedKey))) {
448                        return;
449                }
450                for (IConsentService next : myConsentService) {
451                        next.completeOperationSuccess(theRequest, myContextConsentServices);
452                }
453        }
454
455        private boolean isRequestAuthorized(RequestDetails theRequestDetails) {
456                boolean retVal = false;
457                if (theRequestDetails != null) {
458                        Object authorizedObj = theRequestDetails.getUserData().get(myRequestAuthorizedKey);
459                        retVal = Boolean.TRUE.equals(authorizedObj);
460                }
461                return retVal;
462        }
463
464        private boolean isSkipServiceForRequest(RequestDetails theRequestDetails) {
465                return isMetadataPath(theRequestDetails) || isMetaOperation(theRequestDetails);
466        }
467
468        private boolean isAllowListedRequest(RequestDetails theRequestDetails) {
469                return isMetadataPath(theRequestDetails) || isMetaOperation(theRequestDetails);
470        }
471
472        private boolean isMetaOperation(RequestDetails theRequestDetails) {
473                return OPERATION_META.equals(theRequestDetails.getOperation());
474        }
475
476        private boolean isMetadataPath(RequestDetails theRequestDetails) {
477                return URL_TOKEN_METADATA.equals(theRequestDetails.getRequestPath());
478        }
479
480        private void validateParameter(Map<String, String[]> theParameterMap) {
481                if (theParameterMap != null) {
482                        if (theParameterMap.containsKey(Constants.PARAM_SEARCH_TOTAL_MODE) && Arrays.stream(theParameterMap.get("_total")).anyMatch("accurate"::equals)) {
483                                throw new InvalidRequestException(Msg.code(2037) + Constants.PARAM_SEARCH_TOTAL_MODE + "=accurate is not permitted on this server");
484                        }
485                        if (theParameterMap.containsKey(Constants.PARAM_SUMMARY) && Arrays.stream(theParameterMap.get("_summary")).anyMatch("count"::equals)) {
486                                throw new InvalidRequestException(Msg.code(2038) + Constants.PARAM_SUMMARY + "=count is not permitted on this server");
487                        }
488                }
489        }
490
491        @SuppressWarnings("unchecked")
492        public static IdentityHashMap<IBaseResource, Boolean> getAlreadySeenResourcesMap(RequestDetails theRequestDetails, String theKey) {
493                IdentityHashMap<IBaseResource, Boolean> alreadySeenResources = (IdentityHashMap<IBaseResource, Boolean>) theRequestDetails.getUserData().get(theKey);
494                if (alreadySeenResources == null) {
495                        alreadySeenResources = new IdentityHashMap<>();
496                        theRequestDetails.getUserData().put(theKey, alreadySeenResources);
497                }
498                return alreadySeenResources;
499        }
500
501        private static ForbiddenOperationException toForbiddenOperationException(ConsentOutcome theOutcome) {
502                IBaseOperationOutcome operationOutcome = null;
503                if (theOutcome.getOperationOutcome() != null) {
504                        operationOutcome = theOutcome.getOperationOutcome();
505                }
506                return new ForbiddenOperationException("Rejected by consent service", operationOutcome);
507        }
508}