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}