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