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