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