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