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}