
001/*- 002 * #%L 003 * HAPI FHIR Subscription Server 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.jpa.subscription.util; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.FhirVersionEnum; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.Hook; 026import ca.uhn.fhir.interceptor.api.Interceptor; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.jpa.model.config.PartitionSettings; 029import ca.uhn.fhir.jpa.model.config.SubscriptionSettings; 030import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionCanonicalizer; 031import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; 032import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscriptionChannelType; 033import ca.uhn.fhir.rest.client.api.IGenericClient; 034import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException; 035import ca.uhn.hapi.converters.canonical.SubscriptionTopicCanonicalizer; 036import jakarta.annotation.Nonnull; 037import org.apache.commons.lang3.Validate; 038import org.hl7.fhir.instance.model.api.IBaseConformance; 039import org.hl7.fhir.instance.model.api.IBaseResource; 040import org.hl7.fhir.r5.model.SubscriptionTopic; 041import org.slf4j.Logger; 042import org.slf4j.LoggerFactory; 043 044import java.util.ArrayList; 045import java.util.List; 046import java.util.regex.Pattern; 047 048/** 049 * This interceptor enforces various rules on Subscriptions, preventing them from being 050 * registered if they don't meet the configured requirements. 051 * 052 * @since 8.2.0 053 * @see #addAllowedCriteriaPattern(String) 054 * @see #setValidateRestHookEndpointIsReachable(boolean) 055 */ 056@Interceptor 057public class SubscriptionRulesInterceptor { 058 059 public static final String CRITERIA_WITH_AT_LEAST_ONE_PARAM = "^[A-Z][A-Za-z0-9]++\\?[a-z].*"; 060 private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionRulesInterceptor.class); 061 private final List<Pattern> myAllowedCriteriaPatterns = new ArrayList<>(); 062 private final FhirContext myFhirContext; 063 private final FhirVersionEnum myVersion; 064 private final SubscriptionCanonicalizer mySubscriptionCanonicalizer; 065 private boolean myValidateRestHookEndpointIsReachable; 066 067 public SubscriptionRulesInterceptor( 068 @Nonnull FhirContext theFhirContext, 069 @Nonnull SubscriptionSettings theSubscriptionSettings, 070 PartitionSettings thePartitionSettings) { 071 Validate.notNull(theFhirContext, "FhirContext must not be null"); 072 Validate.notNull(theSubscriptionSettings, "SubscriptionSettings must not be null"); 073 074 myFhirContext = theFhirContext; 075 myVersion = myFhirContext.getVersion().getVersion(); 076 mySubscriptionCanonicalizer = 077 new SubscriptionCanonicalizer(myFhirContext, theSubscriptionSettings, thePartitionSettings); 078 } 079 080 /** 081 * Specifies a regular expression pattern which any Subscription (or SubscriptionTopic on R5+) 082 * must match. If more than one pattern is supplied, the pattern must match at least one 083 * pattern to be accepted, but does not need to match all of them. 084 */ 085 public void addAllowedCriteriaPattern(@Nonnull String theAllowedCriteriaPattern) { 086 Validate.notBlank(theAllowedCriteriaPattern, "Allowed criteria pattern must not be null"); 087 myAllowedCriteriaPatterns.add(Pattern.compile(theAllowedCriteriaPattern)); 088 } 089 090 /** 091 * If true, Subscriptions with a type of "rest-hook" will be tested to ensure that the 092 * endpoint is accessible. If it is not, the subscription will be blocked. 093 */ 094 public void setValidateRestHookEndpointIsReachable(boolean theValidateRestHookEndpointIsReachable) { 095 myValidateRestHookEndpointIsReachable = theValidateRestHookEndpointIsReachable; 096 } 097 098 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) 099 public void validateCreate(IBaseResource theResource) { 100 checkResource(theResource); 101 } 102 103 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) 104 public void validateUpdate(IBaseResource theOldResource, IBaseResource theResource) { 105 checkResource(theResource); 106 } 107 108 private void checkResource(IBaseResource theResource) { 109 110 // From R4B onward, SubscriptionTopic exists and houses the criteria 111 if (myVersion.isEqualOrNewerThan(FhirVersionEnum.R4B)) { 112 if ("SubscriptionTopic".equals(myFhirContext.getResourceType(theResource))) { 113 validateSubscriptionTopic(theResource); 114 } 115 } else if (myVersion.equals(FhirVersionEnum.R4)) { 116 if ("Basic".equals(myFhirContext.getResourceType(theResource))) { 117 validateSubscriptionTopic(theResource); 118 } 119 } 120 121 // As of R5, there is no longer a criteria defined on the Subscription itself 122 if (myVersion.isOlderThan(FhirVersionEnum.R5)) { 123 if ("Subscription".equals(myFhirContext.getResourceType(theResource))) { 124 validateSubscription(theResource); 125 } 126 } 127 } 128 129 private void validateSubscriptionTopic(IBaseResource theResource) { 130 SubscriptionTopic topic = SubscriptionTopicCanonicalizer.canonicalizeTopic(myFhirContext, theResource); 131 132 if (topic != null) { 133 ourLog.info("Validating SubscriptionTopic: {}", theResource.getIdElement()); 134 for (SubscriptionTopic.SubscriptionTopicResourceTriggerComponent resourceTrigger : 135 topic.getResourceTrigger()) { 136 String criteriaString = resourceTrigger.getQueryCriteria().getCurrent(); 137 validateCriteriaString(criteriaString); 138 } 139 } 140 } 141 142 private void validateSubscription(IBaseResource theResource) { 143 ourLog.info("Validating Subscription: {}", theResource.getIdElement()); 144 145 CanonicalSubscription canonicalizedSubscription = mySubscriptionCanonicalizer.canonicalize(theResource); 146 validateCriteriaString(canonicalizedSubscription.getCriteriaString()); 147 validateEndpointIsReachable(canonicalizedSubscription); 148 } 149 150 @SuppressWarnings("unchecked") 151 private void validateEndpointIsReachable(CanonicalSubscription canonicalizedSubscription) { 152 if (myValidateRestHookEndpointIsReachable 153 && canonicalizedSubscription.getChannelType() == CanonicalSubscriptionChannelType.RESTHOOK) { 154 String endpointUrl = canonicalizedSubscription.getEndpointUrl(); 155 IGenericClient client = myFhirContext.newRestfulGenericClient(endpointUrl); 156 Class<? extends IBaseConformance> capabilityStatement = (Class<? extends IBaseConformance>) 157 myFhirContext.getResourceDefinition("CapabilityStatement").getImplementingClass(); 158 try { 159 client.capabilities().ofType(capabilityStatement).execute(); 160 } catch (Exception e) { 161 String message = "REST HOOK endpoint is not reachable: " + endpointUrl; 162 ourLog.warn(message); 163 throw new PreconditionFailedException(Msg.code(2671) + message); 164 } 165 } 166 } 167 168 private void validateCriteriaString(String theCriteriaString) { 169 if (!myAllowedCriteriaPatterns.isEmpty()) { 170 for (Pattern pattern : myAllowedCriteriaPatterns) { 171 if (pattern.matcher(theCriteriaString).matches()) { 172 return; 173 } 174 } 175 String message = "Criteria is not permitted on this server: " + theCriteriaString; 176 ourLog.warn(message); 177 throw new PreconditionFailedException(Msg.code(2672) + message); 178 } 179 } 180}