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