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