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}