001/*-
002 * #%L
003 * HAPI FHIR - Server Framework
004 * %%
005 * Copyright (C) 2014 - 2024 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;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.interceptor.api.Hook;
024import ca.uhn.fhir.interceptor.api.Interceptor;
025import ca.uhn.fhir.interceptor.api.Pointcut;
026import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
027import ca.uhn.fhir.rest.server.method.BaseMethodBinding;
028import ca.uhn.fhir.rest.server.method.OperationMethodBinding;
029import jakarta.annotation.Nonnull;
030import org.apache.commons.lang3.Validate;
031import org.slf4j.Logger;
032import org.slf4j.LoggerFactory;
033
034import java.util.Collections;
035import java.util.HashSet;
036import java.util.Set;
037import java.util.TreeSet;
038
039import static org.apache.commons.lang3.StringUtils.isBlank;
040
041/**
042 * This interceptor can be used to selectively block specific interactions/operations from
043 * the server's capabilities. This interceptor must be configured and registered to a
044 * {@link ca.uhn.fhir.rest.server.RestfulServer} prior to any resource provider
045 * classes being registered to it. This interceptor will then examine any
046 * provider classes being registered and may choose to discard some or all
047 * of the method bindings on each provider.
048 * <p>
049 * For example, if this interceptor is configured to block resource creation, then
050 * when a resource provider is registered that has both a
051 * {@link ca.uhn.fhir.rest.annotation.Read @Read} method and a
052 * {@link ca.uhn.fhir.rest.annotation.Create @Create} method, the
053 * create method will be ignored and not bound.
054 * </p>
055 * <p>
056 * Note: This interceptor is not a security interceptor! It can be used to remove
057 * writes capabilities from a FHIR endpoint (for example) but it does not guarantee
058 * that writes won't be possible. Security rules should be enforced using
059 * {@link ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor} or
060 * a similar strategy. However, this interceptor can be useful in order to
061 * clarify the intent of an endpoint to the outside world. Of particular note,
062 * even if a create method has been blocked from binding by this interceptor,
063 * it may still be possible to create resources via a FHIR transaction unless
064 * proper security has been implemented.
065 * </p>
066 * <p>
067 * Use {@link Builder new Builder()} to create a new instance of this class.
068 * </p>
069 *
070 * @see Builder#addAllowedSpec(String) to add allowed interactions
071 * @since 6.2.0
072 */
073@Interceptor
074public class InteractionBlockingInterceptor {
075
076        public static final Set<RestOperationTypeEnum> ALLOWED_OP_TYPES;
077        private static final Logger ourLog = LoggerFactory.getLogger(InteractionBlockingInterceptor.class);
078
079        static {
080                Set<RestOperationTypeEnum> allowedOpTypes = new TreeSet<>();
081                allowedOpTypes.add(RestOperationTypeEnum.META);
082                allowedOpTypes.add(RestOperationTypeEnum.META_ADD);
083                allowedOpTypes.add(RestOperationTypeEnum.META_DELETE);
084                allowedOpTypes.add(RestOperationTypeEnum.PATCH);
085                allowedOpTypes.add(RestOperationTypeEnum.READ);
086                allowedOpTypes.add(RestOperationTypeEnum.CREATE);
087                allowedOpTypes.add(RestOperationTypeEnum.UPDATE);
088                allowedOpTypes.add(RestOperationTypeEnum.DELETE);
089                allowedOpTypes.add(RestOperationTypeEnum.BATCH);
090                allowedOpTypes.add(RestOperationTypeEnum.TRANSACTION);
091                allowedOpTypes.add(RestOperationTypeEnum.VALIDATE);
092                allowedOpTypes.add(RestOperationTypeEnum.SEARCH_TYPE);
093                allowedOpTypes.add(RestOperationTypeEnum.HISTORY_TYPE);
094                allowedOpTypes.add(RestOperationTypeEnum.HISTORY_INSTANCE);
095                allowedOpTypes.add(RestOperationTypeEnum.HISTORY_SYSTEM);
096                ALLOWED_OP_TYPES = Collections.unmodifiableSet(allowedOpTypes);
097        }
098
099        private final Set<String> myAllowedKeys;
100
101        /**
102         * Constructor
103         */
104        private InteractionBlockingInterceptor(@Nonnull Builder theBuilder) {
105                myAllowedKeys = theBuilder.myAllowedKeys;
106        }
107
108        @Hook(Pointcut.SERVER_PROVIDER_METHOD_BOUND)
109        public BaseMethodBinding bindMethod(BaseMethodBinding theMethodBinding) {
110
111                boolean allowed = true;
112                String resourceName = theMethodBinding.getResourceName();
113                RestOperationTypeEnum restOperationType = theMethodBinding.getRestOperationType();
114                switch (restOperationType) {
115                        case EXTENDED_OPERATION_SERVER:
116                        case EXTENDED_OPERATION_TYPE:
117                        case EXTENDED_OPERATION_INSTANCE: {
118                                OperationMethodBinding operationMethodBinding = (OperationMethodBinding) theMethodBinding;
119                                if (!myAllowedKeys.isEmpty()) {
120                                        if (!myAllowedKeys.contains(operationMethodBinding.getName())) {
121                                                allowed = false;
122                                        }
123                                }
124                                break;
125                        }
126                        default: {
127                                if (restOperationType == RestOperationTypeEnum.VREAD) {
128                                        restOperationType = RestOperationTypeEnum.READ;
129                                }
130                                String key = toKey(resourceName, restOperationType);
131                                if (!myAllowedKeys.isEmpty()) {
132                                        if (!myAllowedKeys.contains(key)) {
133                                                allowed = false;
134                                        }
135                                }
136                                break;
137                        }
138                }
139
140                if (!allowed) {
141                        ourLog.info(
142                                        "Skipping method binding for {}:{} provided by {}",
143                                        resourceName,
144                                        restOperationType,
145                                        theMethodBinding.getMethod());
146                        return null;
147                }
148
149                return theMethodBinding;
150        }
151
152        private static String toKey(String theResourceType, RestOperationTypeEnum theRestOperationTypeEnum) {
153                if (isBlank(theResourceType)) {
154                        return theRestOperationTypeEnum.getCode();
155                }
156                return theResourceType + ":" + theRestOperationTypeEnum.getCode();
157        }
158
159        public static class Builder {
160
161                private final Set<String> myAllowedKeys = new HashSet<>();
162                private final FhirContext myCtx;
163
164                /**
165                 * Constructor
166                 */
167                public Builder(@Nonnull FhirContext theCtx) {
168                        Validate.notNull(theCtx, "theCtx must not be null");
169                        myCtx = theCtx;
170                }
171
172                /**
173                 * Adds an interaction or operation that will be permitted. Allowable formats
174                 * are:
175                 * <ul>
176                 *    <li>
177                 *       <b>[resourceType]:[interaction]</b> - Use this form to allow type- and instance-level interactions, such as
178                 *       <code>create</code>, <code>read</code>, and <code>patch</code>. For example, the spec <code>Patient:create</code>
179                 *       allows the Patient-level create operation (i.e. <code>POST /Patient</code>).
180                 *    </li>
181                 *    <li>
182                 *       <b>$[operation-name]</b> - Use this form to allow operations (at any level) by name. For example, the spec
183                 *       <code>$diff</code> permits the <a href="https://hapifhir.io/hapi-fhir/docs/server_jpa/diff.html">Diff Operation</a>
184                 *       to be applied at both the server- and instance-level.
185                 *    </li>
186                 * </ul>
187                 * <p>
188                 * Note that the spec does not differentiate between the <code>read</code> and <code>vread</code> interactions. If one
189                 * is permitted the other will also be permitted.
190                 * </p>
191                 *
192                 * @return
193                 */
194                public Builder addAllowedSpec(String theSpec) {
195                        Validate.notBlank(theSpec, "theSpec must not be null or blank");
196
197                        if (theSpec.startsWith("$")) {
198                                addAllowedOperation(theSpec);
199                                return this;
200                        }
201
202                        int colonIdx = theSpec.indexOf(':');
203                        Validate.isTrue(colonIdx > 0, "Invalid interaction allowed spec: %s", theSpec);
204
205                        String resourceName = theSpec.substring(0, colonIdx);
206                        String interactionName = theSpec.substring(colonIdx + 1);
207                        if (interactionName.equals("search")) {
208                                interactionName = "search-type";
209                                validateInteraction(interactionName, theSpec, resourceName);
210                        } else if (interactionName.equals("history")) {
211                                validateInteraction("history-instance", theSpec, resourceName);
212                                validateInteraction("history-type", theSpec, resourceName);
213                        } else {
214                                validateInteraction(interactionName, theSpec, resourceName);
215                        }
216                        return this;
217                }
218
219                private void validateInteraction(String theInteractionName, String theSpec, String theResourceName) {
220                        RestOperationTypeEnum interaction = RestOperationTypeEnum.forCode(theInteractionName);
221                        Validate.notNull(interaction, "Unknown interaction %s in spec %s", theInteractionName, theSpec);
222                        addAllowedInteraction(theResourceName, interaction);
223                }
224
225                /**
226                 * Adds an interaction that will be permitted.
227                 */
228                private void addAllowedInteraction(String theResourceType, RestOperationTypeEnum theInteractionType) {
229                        Validate.notBlank(theResourceType, "theResourceType must not be null or blank");
230                        Validate.notNull(theInteractionType, "theInteractionType must not be null");
231                        Validate.isTrue(
232                                        ALLOWED_OP_TYPES.contains(theInteractionType),
233                                        "Operation type %s can not be used as an allowable rule",
234                                        theInteractionType);
235                        Validate.isTrue(myCtx.getResourceType(theResourceType) != null, "Unknown resource type: %s");
236                        String key = toKey(theResourceType, theInteractionType);
237                        myAllowedKeys.add(key);
238                }
239
240                private void addAllowedOperation(String theOperationName) {
241                        Validate.notBlank(theOperationName, "theOperationName must not be null or blank");
242                        Validate.isTrue(theOperationName.startsWith("$"), "Invalid operation name: %s", theOperationName);
243                        myAllowedKeys.add(theOperationName);
244                }
245
246                public InteractionBlockingInterceptor build() {
247                        return new InteractionBlockingInterceptor(this);
248                }
249        }
250}