001/*-
002 * #%L
003 * HAPI FHIR - Server Framework
004 * %%
005 * Copyright (C) 2014 - 2025 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        private final FhirContext myFhirContext;
102
103        /**
104         * Constructor
105         */
106        private InteractionBlockingInterceptor(@Nonnull Builder theBuilder, FhirContext theFhirContext) {
107                myAllowedKeys = theBuilder.myAllowedKeys;
108                myFhirContext = theFhirContext;
109        }
110
111        @Hook(Pointcut.SERVER_PROVIDER_METHOD_BOUND)
112        public BaseMethodBinding bindMethod(BaseMethodBinding theMethodBinding) {
113
114                boolean allowed = true;
115                String resourceName = theMethodBinding.getResourceName();
116                RestOperationTypeEnum restOperationType = theMethodBinding.getRestOperationType();
117                switch (restOperationType) {
118                        case EXTENDED_OPERATION_SERVER:
119                        case EXTENDED_OPERATION_TYPE:
120                        case EXTENDED_OPERATION_INSTANCE: {
121                                OperationMethodBinding operationMethodBinding = (OperationMethodBinding) theMethodBinding;
122                                if (!myAllowedKeys.isEmpty()) {
123                                        if (!myAllowedKeys.contains(operationMethodBinding.getName())) {
124                                                allowed = false;
125                                        }
126                                }
127                                break;
128                        }
129                        case GET_PAGE:
130                                /*
131                                 * PageProvider is registered without type;
132                                 * we need to verify each page individually
133                                 */
134                                for (String resourceType : myFhirContext.getResourceTypes()) {
135                                        String key = toKey(resourceType, RestOperationTypeEnum.SEARCH_TYPE);
136                                        /*
137                                         * We allow PageProvider to be registered if *any*
138                                         * resource:search is registered.
139                                         *
140                                         * This won't cause any security leaks because
141                                         * in order to get a second page, you must first get the first
142                                         * page (ie, resource:search must be allowed).
143                                         *
144                                         * So any _getpages we receive must necessarily also be allowed.
145                                         */
146                                        allowed = myAllowedKeys.contains(key);
147                                        if (allowed) {
148                                                break;
149                                        }
150                                }
151                                break;
152                        default: {
153                                if (restOperationType == RestOperationTypeEnum.VREAD) {
154                                        restOperationType = RestOperationTypeEnum.READ;
155                                }
156                                String key = toKey(resourceName, restOperationType);
157                                if (!myAllowedKeys.isEmpty()) {
158                                        if (!myAllowedKeys.contains(key)) {
159                                                allowed = false;
160                                        }
161                                }
162                                break;
163                        }
164                }
165
166                if (!allowed) {
167                        ourLog.info(
168                                        "Skipping method binding for {}:{} provided by {}",
169                                        resourceName,
170                                        restOperationType,
171                                        theMethodBinding.getMethod());
172                        return null;
173                }
174
175                return theMethodBinding;
176        }
177
178        private static String toKey(String theResourceType, RestOperationTypeEnum theRestOperationTypeEnum) {
179                if (isBlank(theResourceType)) {
180                        return theRestOperationTypeEnum.getCode();
181                }
182                return theResourceType + ":" + theRestOperationTypeEnum.getCode();
183        }
184
185        public static class Builder {
186
187                private final Set<String> myAllowedKeys = new HashSet<>();
188                private final FhirContext myCtx;
189
190                /**
191                 * Constructor
192                 */
193                public Builder(@Nonnull FhirContext theCtx) {
194                        Validate.notNull(theCtx, "theCtx must not be null");
195                        myCtx = theCtx;
196                }
197
198                /**
199                 * Adds an interaction or operation that will be permitted. Allowable formats
200                 * are:
201                 * <ul>
202                 *    <li>
203                 *       <b>[resourceType]:[interaction]</b> - Use this form to allow type- and instance-level interactions, such as
204                 *       <code>create</code>, <code>read</code>, and <code>patch</code>. For example, the spec <code>Patient:create</code>
205                 *       allows the Patient-level create operation (i.e. <code>POST /Patient</code>).
206                 *    </li>
207                 *    <li>
208                 *       <b>$[operation-name]</b> - Use this form to allow operations (at any level) by name. For example, the spec
209                 *       <code>$diff</code> permits the <a href="https://hapifhir.io/hapi-fhir/docs/server_jpa/diff.html">Diff Operation</a>
210                 *       to be applied at both the server- and instance-level.
211                 *    </li>
212                 * </ul>
213                 * <p>
214                 * Note that the spec does not differentiate between the <code>read</code> and <code>vread</code> interactions. If one
215                 * is permitted the other will also be permitted.
216                 * </p>
217                 *
218                 * @return
219                 */
220                public Builder addAllowedSpec(String theSpec) {
221                        Validate.notBlank(theSpec, "theSpec must not be null or blank");
222
223                        if (theSpec.startsWith("$")) {
224                                addAllowedOperation(theSpec);
225                                return this;
226                        }
227
228                        int colonIdx = theSpec.indexOf(':');
229                        Validate.isTrue(colonIdx > 0, "Invalid interaction allowed spec: %s", theSpec);
230
231                        String resourceName = theSpec.substring(0, colonIdx);
232                        String interactionName = theSpec.substring(colonIdx + 1);
233                        if (interactionName.equals("search")) {
234                                interactionName = "search-type";
235                                validateInteraction(interactionName, theSpec, resourceName);
236                        } else if (interactionName.equals("history")) {
237                                validateInteraction("history-instance", theSpec, resourceName);
238                                validateInteraction("history-type", theSpec, resourceName);
239                        } else {
240                                validateInteraction(interactionName, theSpec, resourceName);
241                        }
242                        return this;
243                }
244
245                private void validateInteraction(String theInteractionName, String theSpec, String theResourceName) {
246                        RestOperationTypeEnum interaction = RestOperationTypeEnum.forCode(theInteractionName);
247                        Validate.notNull(interaction, "Unknown interaction %s in spec %s", theInteractionName, theSpec);
248                        addAllowedInteraction(theResourceName, interaction);
249                }
250
251                /**
252                 * Adds an interaction that will be permitted.
253                 */
254                private void addAllowedInteraction(String theResourceType, RestOperationTypeEnum theInteractionType) {
255                        Validate.notBlank(theResourceType, "theResourceType must not be null or blank");
256                        Validate.notNull(theInteractionType, "theInteractionType must not be null");
257                        Validate.isTrue(
258                                        ALLOWED_OP_TYPES.contains(theInteractionType),
259                                        "Operation type %s can not be used as an allowable rule",
260                                        theInteractionType);
261                        Validate.isTrue(myCtx.getResourceType(theResourceType) != null, "Unknown resource type: %s");
262                        String key = toKey(theResourceType, theInteractionType);
263                        myAllowedKeys.add(key);
264                }
265
266                private void addAllowedOperation(String theOperationName) {
267                        Validate.notBlank(theOperationName, "theOperationName must not be null or blank");
268                        Validate.isTrue(theOperationName.startsWith("$"), "Invalid operation name: %s", theOperationName);
269                        myAllowedKeys.add(theOperationName);
270                }
271
272                public InteractionBlockingInterceptor build() {
273                        return new InteractionBlockingInterceptor(this, myCtx);
274                }
275        }
276}