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}