001package ca.uhn.fhir.rest.server.interceptor.auth;
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.FhirContext;
024import ca.uhn.fhir.context.support.IValidationSupport;
025import ca.uhn.fhir.i18n.Msg;
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.RestOperationTypeEnum;
030import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
031import ca.uhn.fhir.rest.api.server.RequestDetails;
032import ca.uhn.fhir.rest.api.server.bulk.BulkDataExportOptions;
033import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
034import ca.uhn.fhir.rest.server.interceptor.consent.ConsentInterceptor;
035import com.google.common.collect.Lists;
036import org.apache.commons.lang3.Validate;
037import org.apache.commons.lang3.builder.ToStringBuilder;
038import org.apache.commons.lang3.builder.ToStringStyle;
039import org.hl7.fhir.instance.model.api.IBaseBundle;
040import org.hl7.fhir.instance.model.api.IBaseParameters;
041import org.hl7.fhir.instance.model.api.IBaseResource;
042import org.hl7.fhir.instance.model.api.IIdType;
043import org.slf4j.Logger;
044import org.slf4j.LoggerFactory;
045
046import javax.annotation.Nonnull;
047import javax.annotation.Nullable;
048import java.util.ArrayList;
049import java.util.Collection;
050import java.util.Collections;
051import java.util.HashSet;
052import java.util.IdentityHashMap;
053import java.util.List;
054import java.util.Set;
055import java.util.concurrent.atomic.AtomicInteger;
056
057import static org.apache.commons.lang3.StringUtils.defaultString;
058import static org.apache.commons.lang3.StringUtils.isNotBlank;
059
060/**
061 * This class is a base class for interceptors which can be used to
062 * inspect requests and responses to determine whether the calling user
063 * has permission to perform the given action.
064 * <p>
065 * See the HAPI FHIR
066 * <a href="https://hapifhir.io/hapi-fhir/docs/security/introduction.html">Documentation on Server Security</a>
067 * for information on how to use this interceptor.
068 * </p>
069 *
070 * @see SearchNarrowingInterceptor
071 */
072@Interceptor(order = AuthorizationConstants.ORDER_AUTH_INTERCEPTOR)
073public class AuthorizationInterceptor implements IRuleApplier {
074
075        public static final String REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS = AuthorizationInterceptor.class.getName() + "_BulkDataExportOptions";
076        private static final AtomicInteger ourInstanceCount = new AtomicInteger(0);
077        private static final Logger ourLog = LoggerFactory.getLogger(AuthorizationInterceptor.class);
078        private final int myInstanceIndex = ourInstanceCount.incrementAndGet();
079        private final String myRequestSeenResourcesKey = AuthorizationInterceptor.class.getName() + "_" + myInstanceIndex + "_SEENRESOURCES";
080        private final String myRequestRuleListKey = AuthorizationInterceptor.class.getName() + "_" + myInstanceIndex + "_RULELIST";
081        private PolicyEnum myDefaultPolicy = PolicyEnum.DENY;
082        private Set<AuthorizationFlagsEnum> myFlags = Collections.emptySet();
083        private IValidationSupport myValidationSupport;
084
085        private IAuthorizationSearchParamMatcher myAuthorizationSearchParamMatcher;
086        private Logger myTroubleshootingLog;
087
088        /**
089         * Constructor
090         */
091        public AuthorizationInterceptor() {
092                super();
093                setTroubleshootingLog(ourLog);
094        }
095
096        /**
097         * Constructor
098         *
099         * @param theDefaultPolicy The default policy if no rules apply (must not be null)
100         */
101        public AuthorizationInterceptor(PolicyEnum theDefaultPolicy) {
102                this();
103                setDefaultPolicy(theDefaultPolicy);
104        }
105
106        @Nonnull
107        @Override
108        public Logger getTroubleshootingLog() {
109                return myTroubleshootingLog;
110        }
111
112        public void setTroubleshootingLog(@Nonnull Logger theTroubleshootingLog) {
113                Validate.notNull(theTroubleshootingLog, "theTroubleshootingLog must not be null");
114                myTroubleshootingLog = theTroubleshootingLog;
115        }
116
117        private void applyRulesAndFailIfDeny(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId,
118                                                                                                         IBaseResource theOutputResource, Pointcut thePointcut) {
119                Verdict decision = applyRulesAndReturnDecision(theOperation, theRequestDetails, theInputResource, theInputResourceId, theOutputResource, thePointcut);
120
121                if (decision.getDecision() == PolicyEnum.ALLOW) {
122                        return;
123                }
124
125                handleDeny(theRequestDetails, decision);
126        }
127
128        @Override
129        public Verdict applyRulesAndReturnDecision(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId,
130                                                                                                                         IBaseResource theOutputResource, Pointcut thePointcut) {
131                @SuppressWarnings("unchecked")
132                List<IAuthRule> rules = (List<IAuthRule>) theRequestDetails.getUserData().get(myRequestRuleListKey);
133                if (rules == null) {
134                        rules = buildRuleList(theRequestDetails);
135                        theRequestDetails.getUserData().put(myRequestRuleListKey, rules);
136                }
137                Set<AuthorizationFlagsEnum> flags = getFlags();
138                ourLog.trace("Applying {} rules to render an auth decision for operation {}, theInputResource type={}, theOutputResource type={} ", rules.size(), theOperation,
139                        ((theInputResource != null) && (theInputResource.getIdElement() != null)) ? theInputResource.getIdElement().getResourceType() : "",
140                        ((theOutputResource != null) && (theOutputResource.getIdElement() != null)) ? theOutputResource.getIdElement().getResourceType() : "");
141
142                Verdict verdict = null;
143                for (IAuthRule nextRule : rules) {
144                        ourLog.trace("Rule being applied - {}", nextRule);
145                        verdict = nextRule.applyRule(theOperation, theRequestDetails, theInputResource, theInputResourceId, theOutputResource, this, flags, thePointcut);
146                        if (verdict != null) {
147                                ourLog.trace("Rule {} returned decision {}", nextRule, verdict.getDecision());
148                                break;
149                        }
150                }
151
152                if (verdict == null) {
153                        ourLog.trace("No rules returned a decision, applying default {}", myDefaultPolicy);
154                        return new Verdict(getDefaultPolicy(), null);
155                }
156
157                return verdict;
158        }
159
160        /**
161         * @since 6.0.0
162         */
163        @Nullable
164        @Override
165        public IValidationSupport getValidationSupport() {
166                return myValidationSupport;
167        }
168
169        /**
170         * Sets a validation support module that will be used for terminology-based rules
171         *
172         * @param theValidationSupport The validation support. Null is also acceptable (this is the default),
173         *                             in which case the validation support module associated with the {@link FhirContext}
174         *                             will be used.
175         * @since 6.0.0
176         */
177        public AuthorizationInterceptor setValidationSupport(IValidationSupport theValidationSupport) {
178                myValidationSupport = theValidationSupport;
179                return this;
180        }
181
182        /**
183         * Sets a search parameter matcher for use in handling SMART v2 filter scopes
184         *
185         * @param theAuthorizationSearchParamMatcher The search parameter matcher. Defaults to null.
186         */
187        public void setAuthorizationSearchParamMatcher(@Nullable IAuthorizationSearchParamMatcher theAuthorizationSearchParamMatcher) {
188                this.myAuthorizationSearchParamMatcher = theAuthorizationSearchParamMatcher;
189        }
190
191        @Nullable
192        public IAuthorizationSearchParamMatcher getSearchParamMatcher() {
193                return myAuthorizationSearchParamMatcher;
194        }
195
196        /**
197         * Subclasses should override this method to supply the set of rules to be applied to
198         * this individual request.
199         * <p>
200         * Typically this is done by examining <code>theRequestDetails</code> to find
201         * out who the current user is and then using a {@link RuleBuilder} to create
202         * an appropriate rule chain.
203         * </p>
204         *
205         * @param theRequestDetails The individual request currently being applied
206         */
207        public List<IAuthRule> buildRuleList(RequestDetails theRequestDetails) {
208                return new ArrayList<>();
209        }
210
211        private OperationExamineDirection determineOperationDirection(RestOperationTypeEnum theOperation, IBaseResource theRequestResource) {
212                switch (theOperation) {
213                        case ADD_TAGS:
214                        case DELETE_TAGS:
215                        case GET_TAGS:
216                                // These are DSTU1 operations and not relevant
217                                return OperationExamineDirection.NONE;
218
219                        case EXTENDED_OPERATION_INSTANCE:
220                        case EXTENDED_OPERATION_SERVER:
221                        case EXTENDED_OPERATION_TYPE:
222                                return OperationExamineDirection.BOTH;
223
224                        case METADATA:
225                                // Security does not apply to these operations
226                                return OperationExamineDirection.IN;
227
228                        case DELETE:
229                                // Delete is a special case
230                                return OperationExamineDirection.IN;
231
232                        case CREATE:
233                        case UPDATE:
234                        case PATCH:
235                                // if (theRequestResource != null) {
236                                // if (theRequestResource.getIdElement() != null) {
237                                // if (theRequestResource.getIdElement().hasIdPart() == false) {
238                                // return OperationExamineDirection.IN_UNCATEGORIZED;
239                                // }
240                                // }
241                                // }
242                                return OperationExamineDirection.IN;
243
244                        case META:
245                        case META_ADD:
246                        case META_DELETE:
247                                // meta operations do not apply yet
248                                return OperationExamineDirection.NONE;
249
250                        case GET_PAGE:
251                        case HISTORY_INSTANCE:
252                        case HISTORY_SYSTEM:
253                        case HISTORY_TYPE:
254                        case READ:
255                        case SEARCH_SYSTEM:
256                        case SEARCH_TYPE:
257                        case VREAD:
258                                return OperationExamineDirection.OUT;
259
260                        case TRANSACTION:
261                                return OperationExamineDirection.BOTH;
262
263                        case VALIDATE:
264                                // Nothing yet
265                                return OperationExamineDirection.NONE;
266
267                        case GRAPHQL_REQUEST:
268                                return OperationExamineDirection.BOTH;
269
270                        default:
271                                // Should not happen
272                                throw new IllegalStateException(Msg.code(332) + "Unable to apply security to event of type " + theOperation);
273                }
274
275        }
276
277        /**
278         * The default policy if no rules have been found to apply. Default value for this setting is {@link PolicyEnum#DENY}
279         */
280        public PolicyEnum getDefaultPolicy() {
281                return myDefaultPolicy;
282        }
283
284        /**
285         * The default policy if no rules have been found to apply. Default value for this setting is {@link PolicyEnum#DENY}
286         *
287         * @param theDefaultPolicy The policy (must not be <code>null</code>)
288         */
289        public AuthorizationInterceptor setDefaultPolicy(PolicyEnum theDefaultPolicy) {
290                Validate.notNull(theDefaultPolicy, "theDefaultPolicy must not be null");
291                myDefaultPolicy = theDefaultPolicy;
292                return this;
293        }
294
295        /**
296         * This property configures any flags affecting how authorization is
297         * applied. By default no flags are applied.
298         *
299         * @see #setFlags(Collection)
300         */
301        public Set<AuthorizationFlagsEnum> getFlags() {
302                return Collections.unmodifiableSet(myFlags);
303        }
304
305        /**
306         * This property configures any flags affecting how authorization is
307         * applied. By default no flags are applied.
308         *
309         * @param theFlags The flags (must not be null)
310         * @see #setFlags(AuthorizationFlagsEnum...)
311         */
312        public AuthorizationInterceptor setFlags(Collection<AuthorizationFlagsEnum> theFlags) {
313                Validate.notNull(theFlags, "theFlags must not be null");
314                myFlags = new HashSet<>(theFlags);
315                return this;
316        }
317
318        /**
319         * This property configures any flags affecting how authorization is
320         * applied. By default no flags are applied.
321         *
322         * @param theFlags The flags (must not be null)
323         * @see #setFlags(Collection)
324         */
325        public AuthorizationInterceptor setFlags(AuthorizationFlagsEnum... theFlags) {
326                Validate.notNull(theFlags, "theFlags must not be null");
327                return setFlags(Lists.newArrayList(theFlags));
328        }
329
330        /**
331         * Handle an access control verdict of {@link PolicyEnum#DENY}.
332         * <p>
333         * Subclasses may override to implement specific behaviour, but default is to
334         * throw {@link ForbiddenOperationException} (HTTP 403) with error message citing the
335         * rule name which trigered failure
336         * </p>
337         *
338         * @since HAPI FHIR 3.6.0
339         */
340        protected void handleDeny(RequestDetails theRequestDetails, Verdict decision) {
341                handleDeny(decision);
342        }
343
344        /**
345         * This method should not be overridden. As of HAPI FHIR 3.6.0, you
346         * should override {@link #handleDeny(RequestDetails, Verdict)} instead. This
347         * method will be removed in the future.
348         */
349        protected void handleDeny(Verdict decision) {
350                if (decision.getDecidingRule() != null) {
351                        String ruleName = defaultString(decision.getDecidingRule().getName(), "(unnamed rule)");
352                        throw new ForbiddenOperationException(Msg.code(333) + "Access denied by rule: " + ruleName);
353                }
354                throw new ForbiddenOperationException(Msg.code(334) + "Access denied by default policy (no applicable rules)");
355        }
356
357        private void handleUserOperation(RequestDetails theRequest, IBaseResource theResource, RestOperationTypeEnum theOperation, Pointcut thePointcut) {
358                applyRulesAndFailIfDeny(theOperation, theRequest, theResource, theResource.getIdElement(), null, thePointcut);
359        }
360
361        @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
362        public void incomingRequestPreHandled(RequestDetails theRequest, Pointcut thePointcut) {
363                IBaseResource inputResource = null;
364                IIdType inputResourceId = null;
365
366                switch (determineOperationDirection(theRequest.getRestOperationType(), theRequest.getResource())) {
367                        case IN:
368                        case BOTH:
369                                inputResource = theRequest.getResource();
370                                inputResourceId = theRequest.getId();
371                                if (inputResourceId == null && isNotBlank(theRequest.getResourceName())) {
372                                        inputResourceId = theRequest.getFhirContext().getVersion().newIdType();
373                                        inputResourceId.setParts(null, theRequest.getResourceName(), null, null);
374                                }
375                                break;
376                        case OUT:
377                                // inputResource = null;
378                                inputResourceId = theRequest.getId();
379                                break;
380                        case NONE:
381                                return;
382                }
383
384                applyRulesAndFailIfDeny(theRequest.getRestOperationType(), theRequest, inputResource, inputResourceId, null, thePointcut);
385        }
386
387        @Hook(Pointcut.STORAGE_PRESHOW_RESOURCES)
388        public void hookPreShow(RequestDetails theRequestDetails, IPreResourceShowDetails theDetails, Pointcut thePointcut) {
389                for (int i = 0; i < theDetails.size(); i++) {
390                        IBaseResource next = theDetails.getResource(i);
391                        checkOutgoingResourceAndFailIfDeny(theRequestDetails, next, thePointcut);
392                }
393        }
394
395        @Hook(Pointcut.SERVER_OUTGOING_RESPONSE)
396        public void hookOutgoingResponse(RequestDetails theRequestDetails, IBaseResource theResponseObject, Pointcut thePointcut) {
397                checkOutgoingResourceAndFailIfDeny(theRequestDetails, theResponseObject, thePointcut);
398        }
399
400        @Hook(Pointcut.STORAGE_CASCADE_DELETE)
401        public void hookCascadeDeleteForConflict(RequestDetails theRequestDetails, Pointcut thePointcut, IBaseResource theResourceToDelete) {
402                Validate.notNull(theResourceToDelete); // just in case
403                checkPointcutAndFailIfDeny(theRequestDetails, thePointcut, theResourceToDelete);
404        }
405
406        @Hook(Pointcut.STORAGE_PRE_DELETE_EXPUNGE)
407        public void hookDeleteExpunge(RequestDetails theRequestDetails, Pointcut thePointcut) {
408                applyRulesAndFailIfDeny(theRequestDetails.getRestOperationType(), theRequestDetails, null, null, null, thePointcut);
409        }
410
411        @Hook(Pointcut.STORAGE_INITIATE_BULK_EXPORT)
412        public void initiateBulkExport(RequestDetails theRequestDetails, BulkDataExportOptions theBulkExportOptions, Pointcut thePointcut) {
413                RestOperationTypeEnum restOperationType = RestOperationTypeEnum.EXTENDED_OPERATION_SERVER;
414                if (theRequestDetails != null) {
415                        theRequestDetails.setAttribute(REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS, theBulkExportOptions);
416                }
417                applyRulesAndFailIfDeny(restOperationType, theRequestDetails, null, null, null, thePointcut);
418        }
419
420        private void checkPointcutAndFailIfDeny(RequestDetails theRequestDetails, Pointcut thePointcut, @Nonnull IBaseResource theInputResource) {
421                applyRulesAndFailIfDeny(theRequestDetails.getRestOperationType(), theRequestDetails, theInputResource, theInputResource.getIdElement(), null, thePointcut);
422        }
423
424        private void checkOutgoingResourceAndFailIfDeny(RequestDetails theRequestDetails, IBaseResource theResponseObject, Pointcut thePointcut) {
425                switch (determineOperationDirection(theRequestDetails.getRestOperationType(), null)) {
426                        case IN:
427                        case NONE:
428                                return;
429                        case BOTH:
430                        case OUT:
431                                break;
432                }
433
434                // Don't check the value twice
435                IdentityHashMap<IBaseResource, Boolean> alreadySeenMap = ConsentInterceptor.getAlreadySeenResourcesMap(theRequestDetails, myRequestSeenResourcesKey);
436                if (alreadySeenMap.putIfAbsent(theResponseObject, Boolean.TRUE) != null) {
437                        return;
438                }
439
440                FhirContext fhirContext = theRequestDetails.getServer().getFhirContext();
441                List<IBaseResource> resources = Collections.emptyList();
442
443                //noinspection EnumSwitchStatementWhichMissesCases
444                switch (theRequestDetails.getRestOperationType()) {
445                        case SEARCH_SYSTEM:
446                        case SEARCH_TYPE:
447                        case HISTORY_INSTANCE:
448                        case HISTORY_SYSTEM:
449                        case HISTORY_TYPE:
450                        case TRANSACTION:
451                        case GET_PAGE:
452                        case EXTENDED_OPERATION_SERVER:
453                        case EXTENDED_OPERATION_TYPE:
454                        case EXTENDED_OPERATION_INSTANCE: {
455                                if (theResponseObject != null) {
456                                        resources = toListOfResourcesAndExcludeContainer(theResponseObject, fhirContext);
457                                }
458                                break;
459                        }
460                        default: {
461                                if (theResponseObject != null) {
462                                        resources = Collections.singletonList(theResponseObject);
463                                }
464                                break;
465                        }
466                }
467
468                for (IBaseResource nextResponse : resources) {
469                        applyRulesAndFailIfDeny(theRequestDetails.getRestOperationType(), theRequestDetails, null, null, nextResponse, thePointcut);
470                }
471        }
472
473        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
474        public void hookResourcePreCreate(RequestDetails theRequest, IBaseResource theResource, Pointcut thePointcut) {
475                handleUserOperation(theRequest, theResource, RestOperationTypeEnum.CREATE, thePointcut);
476        }
477
478        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_DELETED)
479        public void hookResourcePreDelete(RequestDetails theRequest, IBaseResource theResource, Pointcut thePointcut) {
480                handleUserOperation(theRequest, theResource, RestOperationTypeEnum.DELETE, thePointcut);
481        }
482
483        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
484        public void hookResourcePreUpdate(RequestDetails theRequest, IBaseResource theOldResource, IBaseResource theNewResource, Pointcut thePointcut) {
485                if (theOldResource != null) {
486                        handleUserOperation(theRequest, theOldResource, RestOperationTypeEnum.UPDATE, thePointcut);
487                }
488                handleUserOperation(theRequest, theNewResource, RestOperationTypeEnum.UPDATE, thePointcut);
489        }
490
491        private enum OperationExamineDirection {
492                BOTH,
493                IN,
494                NONE,
495                OUT,
496        }
497
498        static List<IBaseResource> toListOfResourcesAndExcludeContainer(IBaseResource theResponseObject, FhirContext fhirContext) {
499                if (theResponseObject == null) {
500                        return Collections.emptyList();
501                }
502
503                List<IBaseResource> retVal;
504
505                boolean isContainer = false;
506                if (theResponseObject instanceof IBaseBundle) {
507                        isContainer = true;
508                } else if (theResponseObject instanceof IBaseParameters) {
509                        isContainer = true;
510                }
511
512                if (!isContainer) {
513                        return Collections.singletonList(theResponseObject);
514                }
515
516                retVal = fhirContext.newTerser().getAllPopulatedChildElementsOfType(theResponseObject, IBaseResource.class);
517
518                // Exclude the container
519                if (retVal.size() > 0 && retVal.get(0) == theResponseObject) {
520                        retVal = retVal.subList(1, retVal.size());
521                }
522
523                return retVal;
524        }
525
526        public static class Verdict {
527
528                private final IAuthRule myDecidingRule;
529                private final PolicyEnum myDecision;
530
531                public Verdict(PolicyEnum theDecision, IAuthRule theDecidingRule) {
532                        Validate.notNull(theDecision);
533
534                        myDecision = theDecision;
535                        myDecidingRule = theDecidingRule;
536                }
537
538                IAuthRule getDecidingRule() {
539                        return myDecidingRule;
540                }
541
542                public PolicyEnum getDecision() {
543                        return myDecision;
544                }
545
546                @Override
547                public String toString() {
548                        ToStringBuilder b = new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE);
549                        String ruleName;
550                        if (myDecidingRule != null) {
551                                ruleName = myDecidingRule.getName();
552                        } else {
553                                ruleName = "(none)";
554                        }
555                        b.append("rule", ruleName);
556                        b.append("decision", myDecision.name());
557                        return b.build();
558                }
559
560        }
561
562}