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.interceptor.api.Hook;
027import ca.uhn.fhir.interceptor.api.Interceptor;
028import ca.uhn.fhir.interceptor.api.Pointcut;
029import ca.uhn.fhir.rest.api.Constants;
030import ca.uhn.fhir.rest.api.server.IPreResourceAccessDetails;
031import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
032import ca.uhn.fhir.rest.api.server.RequestDetails;
033import ca.uhn.fhir.rest.api.server.ResponseDetails;
034import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
035import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
036import ca.uhn.fhir.rest.server.util.ICachedSearchDetails;
037import ca.uhn.fhir.util.BundleUtil;
038import ca.uhn.fhir.util.IModelVisitor2;
039import org.apache.commons.lang3.Validate;
040import org.hl7.fhir.instance.model.api.*;
041
042import java.util.IdentityHashMap;
043import java.util.List;
044import java.util.Map;
045import java.util.concurrent.atomic.AtomicInteger;
046
047import static ca.uhn.fhir.rest.api.Constants.URL_TOKEN_METADATA;
048import static ca.uhn.fhir.rest.server.provider.ProviderConstants.OPERATION_META;
049
050@Interceptor
051public class ConsentInterceptor {
052        private static final AtomicInteger ourInstanceCount = new AtomicInteger(0);
053        private final int myInstanceIndex = ourInstanceCount.incrementAndGet();
054        private final String myRequestAuthorizedKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_AUTHORIZED";
055        private final String myRequestCompletedKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_COMPLETED";
056        private final String myRequestSeenResourcesKey = ConsentInterceptor.class.getName() + "_" + myInstanceIndex + "_SEENRESOURCES";
057
058        private IConsentService myConsentService;
059        private IConsentContextServices myContextConsentServices;
060
061        /**
062         * Constructor
063         */
064        public ConsentInterceptor() {
065                super();
066        }
067
068        /**
069         * Constructor
070         *
071         * @param theConsentService         Must not be <code>null</code>
072         */
073        public ConsentInterceptor(IConsentService theConsentService) {
074                this(theConsentService, IConsentContextServices.NULL_IMPL);
075        }
076
077        /**
078         * Constructor
079         *
080         * @param theConsentService         Must not be <code>null</code>
081         * @param theContextConsentServices Must not be <code>null</code>
082         */
083        public ConsentInterceptor(IConsentService theConsentService, IConsentContextServices theContextConsentServices) {
084                setConsentService(theConsentService);
085                setContextConsentServices(theContextConsentServices);
086        }
087
088        public void setContextConsentServices(IConsentContextServices theContextConsentServices) {
089                Validate.notNull(theContextConsentServices, "theContextConsentServices must not be null");
090                myContextConsentServices = theContextConsentServices;
091        }
092
093        public void setConsentService(IConsentService theConsentService) {
094                Validate.notNull(theConsentService, "theConsentService must not be null");
095                myConsentService = theConsentService;
096        }
097
098        @Hook(value = Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
099        public void interceptPreHandled(RequestDetails theRequestDetails) {
100                if (isAllowListedRequest(theRequestDetails)) {
101                        return;
102                }
103                ConsentOutcome outcome = myConsentService.startOperation(theRequestDetails, myContextConsentServices);
104                Validate.notNull(outcome, "Consent service returned null outcome");
105
106                switch (outcome.getStatus()) {
107                        case REJECT:
108                                throw toForbiddenOperationException(outcome);
109                        case PROCEED:
110                                break;
111                        case AUTHORIZED:
112                                Map<Object, Object> userData = theRequestDetails.getUserData();
113                                userData.put(myRequestAuthorizedKey, Boolean.TRUE);
114                                break;
115                }
116        }
117
118        @Hook(value = Pointcut.STORAGE_PRECHECK_FOR_CACHED_SEARCH)
119        public boolean interceptPreCheckForCachedSearch(RequestDetails theRequestDetails) {
120                if (isRequestAuthorized(theRequestDetails)) {
121                        return true;
122                }
123                return false;
124        }
125
126        @Hook(value = Pointcut.STORAGE_PRESEARCH_REGISTERED)
127        public void interceptPreSearchRegistered(RequestDetails theRequestDetails, ICachedSearchDetails theCachedSearchDetails) {
128                if (!isRequestAuthorized(theRequestDetails)) {
129                        theCachedSearchDetails.setCannotBeReused();
130                }
131        }
132
133        @Hook(value = Pointcut.STORAGE_PREACCESS_RESOURCES)
134        public void interceptPreAccess(RequestDetails theRequestDetails, IPreResourceAccessDetails thePreResourceAccessDetails) {
135                if (isRequestAuthorized(theRequestDetails)) {
136                        return;
137                }
138                if (isAllowListedRequest(theRequestDetails)) {
139                        return;
140                }
141
142                for (int i = 0; i < thePreResourceAccessDetails.size(); i++) {
143                        IBaseResource nextResource = thePreResourceAccessDetails.getResource(i);
144                        ConsentOutcome nextOutcome = myConsentService.canSeeResource(theRequestDetails, nextResource, myContextConsentServices);
145                        switch (nextOutcome.getStatus()) {
146                                case PROCEED:
147                                        break;
148                                case AUTHORIZED:
149                                        break;
150                                case REJECT:
151                                        thePreResourceAccessDetails.setDontReturnResourceAtIndex(i);
152                                        break;
153                        }
154                }
155        }
156
157        @Hook(value = Pointcut.STORAGE_PRESHOW_RESOURCES)
158        public void interceptPreShow(RequestDetails theRequestDetails, IPreResourceShowDetails thePreResourceShowDetails) {
159                if (isRequestAuthorized(theRequestDetails)) {
160                        return;
161                }
162                if (isAllowListedRequest(theRequestDetails)) {
163                        return;
164                }
165                IdentityHashMap<IBaseResource, Boolean> alreadySeenResources = getAlreadySeenResourcesMap(theRequestDetails);
166
167                for (int i = 0; i < thePreResourceShowDetails.size(); i++) {
168                        IBaseResource nextResource = thePreResourceShowDetails.getResource(i);
169                        if (alreadySeenResources.putIfAbsent(nextResource, Boolean.TRUE) != null) {
170                                continue;
171                        }
172
173                        ConsentOutcome nextOutcome = myConsentService.willSeeResource(theRequestDetails, nextResource, myContextConsentServices);
174                        switch (nextOutcome.getStatus()) {
175                                case PROCEED:
176                                        if (nextOutcome.getResource() != null) {
177                                                thePreResourceShowDetails.setResource(i, nextOutcome.getResource());
178                                        }
179                                        break;
180                                case AUTHORIZED:
181                                        break;
182                                case REJECT:
183                                        if (nextOutcome.getResource() != null) {
184                                                IBaseResource newResource = nextOutcome.getResource();
185                                                thePreResourceShowDetails.setResource(i, newResource);
186                                                alreadySeenResources.put(newResource, true);
187                                        } else if (nextOutcome.getOperationOutcome() != null) {
188                                                IBaseOperationOutcome newOperationOutcome = nextOutcome.getOperationOutcome();
189                                                thePreResourceShowDetails.setResource(i, newOperationOutcome);
190                                                alreadySeenResources.put(newOperationOutcome, true);
191                                        } else {
192                                                String resourceId = nextResource.getIdElement().getValue();
193                                                thePreResourceShowDetails.setResource(i, null);
194                                                nextResource.setId(resourceId);
195                                        }
196                                        break;
197                        }
198                }
199        }
200
201        private IdentityHashMap<IBaseResource, Boolean> getAlreadySeenResourcesMap(RequestDetails theRequestDetails) {
202                return getAlreadySeenResourcesMap(theRequestDetails, myRequestSeenResourcesKey);
203        }
204
205        @Hook(value = Pointcut.SERVER_OUTGOING_RESPONSE)
206        public void interceptOutgoingResponse(RequestDetails theRequestDetails, ResponseDetails theResource) {
207                if (theResource.getResponseResource() == null) {
208                        return;
209                }
210                if (isRequestAuthorized(theRequestDetails)) {
211                        return;
212                }
213                if (isAllowListedRequest(theRequestDetails)) {
214                        return;
215                }
216
217                IdentityHashMap<IBaseResource, Boolean> alreadySeenResources = getAlreadySeenResourcesMap(theRequestDetails);
218
219                // See outer resource
220                if (alreadySeenResources.putIfAbsent(theResource.getResponseResource(), Boolean.TRUE) == null) {
221                        final ConsentOutcome outcome = myConsentService.willSeeResource(theRequestDetails, theResource.getResponseResource(), myContextConsentServices);
222                        if (outcome.getResource() != null) {
223                                theResource.setResponseResource(outcome.getResource());
224                        }
225
226                        // Clear the total
227                        if (theResource.getResponseResource() instanceof IBaseBundle) {
228                                BundleUtil.setTotal(theRequestDetails.getFhirContext(), (IBaseBundle) theResource.getResponseResource(), null);
229                        }
230
231                        switch (outcome.getStatus()) {
232                                case REJECT:
233                                        if (outcome.getOperationOutcome() != null) {
234                                                theResource.setResponseResource(outcome.getOperationOutcome());
235                                        } else {
236                                                theResource.setResponseResource(null);
237                                                theResource.setResponseCode(Constants.STATUS_HTTP_204_NO_CONTENT);
238                                        }
239                                        return;
240                                case AUTHORIZED:
241                                        // Don't check children
242                                        return;
243                                case PROCEED:
244                                        // Check children
245                                        break;
246                        }
247                }
248
249                // See child resources
250                IBaseResource outerResource = theResource.getResponseResource();
251                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
252                IModelVisitor2 visitor = new IModelVisitor2() {
253                        @Override
254                        public boolean acceptElement(IBase theElement, List<IBase> theContainingElementPath, List<BaseRuntimeChildDefinition> theChildDefinitionPath, List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) {
255
256                                // Clear the total
257                                if (theElement instanceof IBaseBundle) {
258                                        BundleUtil.setTotal(theRequestDetails.getFhirContext(), (IBaseBundle) theElement, null);
259                                }
260
261                                if (theElement == outerResource) {
262                                        return true;
263                                }
264                                if (theElement instanceof IBaseResource) {
265                                        if (alreadySeenResources.putIfAbsent((IBaseResource) theElement, Boolean.TRUE) != null) {
266                                                return true;
267                                        }
268                                        ConsentOutcome childOutcome = myConsentService.willSeeResource(theRequestDetails, (IBaseResource) theElement, myContextConsentServices);
269
270                                        IBaseResource replacementResource = null;
271                                        boolean shouldReplaceResource = false;
272                                        boolean shouldCheckChildren = false;
273
274                                        switch (childOutcome.getStatus()) {
275                                                case REJECT:
276                                                        replacementResource = childOutcome.getOperationOutcome();
277                                                        shouldReplaceResource = true;
278                                                        break;
279                                                case PROCEED:
280                                                case AUTHORIZED:
281                                                        replacementResource = childOutcome.getResource();
282                                                        shouldReplaceResource = replacementResource != null;
283                                                        shouldCheckChildren = childOutcome.getStatus() == ConsentOperationStatusEnum.PROCEED;
284                                                        break;
285                                        }
286
287                                        if (shouldReplaceResource) {
288                                                IBase container = theContainingElementPath.get(theContainingElementPath.size() - 2);
289                                                BaseRuntimeChildDefinition containerChildElement = theChildDefinitionPath.get(theChildDefinitionPath.size() - 1);
290                                                containerChildElement.getMutator().setValue(container, replacementResource);
291                                        }
292
293                                        return shouldCheckChildren;
294                                }
295
296                                return true;
297                        }
298
299                        @Override
300                        public boolean acceptUndeclaredExtension(IBaseExtension<?, ?> theNextExt, List<IBase> theContainingElementPath, List<BaseRuntimeChildDefinition> theChildDefinitionPath, List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) {
301                                return true;
302                        }
303                };
304                ctx.newTerser().visit(outerResource, visitor);
305
306        }
307
308        @Hook(value = Pointcut.SERVER_HANDLE_EXCEPTION)
309        public void requestFailed(RequestDetails theRequest, BaseServerResponseException theException) {
310                theRequest.getUserData().put(myRequestCompletedKey, Boolean.TRUE);
311                myConsentService.completeOperationFailure(theRequest, theException, myContextConsentServices);
312        }
313
314        @Hook(value = Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY)
315        public void requestSucceeded(RequestDetails theRequest) {
316                if (Boolean.TRUE.equals(theRequest.getUserData().get(myRequestCompletedKey))) {
317                        return;
318                }
319                myConsentService.completeOperationSuccess(theRequest, myContextConsentServices);
320        }
321
322        private boolean isRequestAuthorized(RequestDetails theRequestDetails) {
323                boolean retVal = false;
324                if (theRequestDetails != null) {
325                        Object authorizedObj = theRequestDetails.getUserData().get(myRequestAuthorizedKey);
326                        retVal = Boolean.TRUE.equals(authorizedObj);
327                }
328                return retVal;
329        }
330
331        @SuppressWarnings("unchecked")
332        public static IdentityHashMap<IBaseResource, Boolean> getAlreadySeenResourcesMap(RequestDetails theRequestDetails, String theKey) {
333                IdentityHashMap<IBaseResource, Boolean> alreadySeenResources = (IdentityHashMap<IBaseResource, Boolean>) theRequestDetails.getUserData().get(theKey);
334                if (alreadySeenResources == null) {
335                        alreadySeenResources = new IdentityHashMap<>();
336                        theRequestDetails.getUserData().put(theKey, alreadySeenResources);
337                }
338                return alreadySeenResources;
339        }
340
341        private static ForbiddenOperationException toForbiddenOperationException(ConsentOutcome theOutcome) {
342                IBaseOperationOutcome operationOutcome = null;
343                if (theOutcome.getOperationOutcome() != null) {
344                        operationOutcome = theOutcome.getOperationOutcome();
345                }
346                return new ForbiddenOperationException("Rejected by consent service", operationOutcome);
347        }
348
349        private boolean isAllowListedRequest(RequestDetails theRequestDetails) {
350                return isMetadataPath(theRequestDetails) || isMetaOperation(theRequestDetails);
351        }
352
353        private boolean isMetaOperation(RequestDetails theRequestDetails) {
354                return OPERATION_META.equals(theRequestDetails.getOperation());
355        }
356
357        private boolean isMetadataPath(RequestDetails theRequestDetails) {
358                return URL_TOKEN_METADATA.equals(theRequestDetails.getRequestPath());
359        }
360}