001/*-
002 * #%L
003 * HAPI FHIR Storage api
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.interceptor;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.interceptor.api.Hook;
027import ca.uhn.fhir.interceptor.api.Interceptor;
028import ca.uhn.fhir.interceptor.api.Pointcut;
029import ca.uhn.fhir.interceptor.auth.CompartmentSearchParameterModifications;
030import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
031import ca.uhn.fhir.interceptor.model.RequestPartitionId;
032import ca.uhn.fhir.jpa.model.config.PartitionSettings;
033import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
034import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
035import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil;
036import ca.uhn.fhir.model.api.IQueryParameterType;
037import ca.uhn.fhir.rest.api.server.RequestDetails;
038import ca.uhn.fhir.rest.param.ReferenceParam;
039import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
040import ca.uhn.fhir.rest.server.provider.ProviderConstants;
041import jakarta.annotation.Nonnull;
042import org.hl7.fhir.instance.model.api.IBaseReference;
043import org.hl7.fhir.instance.model.api.IBaseResource;
044import org.hl7.fhir.instance.model.api.IIdType;
045import org.hl7.fhir.r4.model.IdType;
046import org.springframework.beans.factory.annotation.Autowired;
047
048import java.util.ArrayList;
049import java.util.Collection;
050import java.util.Collections;
051import java.util.List;
052import java.util.Objects;
053import java.util.Optional;
054
055import static ca.uhn.fhir.interceptor.model.RequestPartitionId.getPartitionIfAssigned;
056import static org.apache.commons.lang3.StringUtils.isBlank;
057import static org.apache.commons.lang3.StringUtils.isEmpty;
058import static org.apache.commons.lang3.StringUtils.isNotBlank;
059
060/**
061 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations
062 * based on the patient ID associated with the resource (and uses a default partition ID for any resources
063 * not in the patient compartment).
064 * This works better with IdStrategyEnum.UUID and CrossPartitionReferenceMode.ALLOWED_UNQUALIFIED.
065 */
066@Interceptor
067public class PatientIdPartitionInterceptor {
068
069        @Autowired
070        private FhirContext myFhirContext;
071
072        @Autowired
073        private ISearchParamExtractor mySearchParamExtractor;
074
075        @Autowired
076        private PartitionSettings myPartitionSettings;
077
078        /**
079         * Constructor
080         */
081        public PatientIdPartitionInterceptor(
082                        FhirContext theFhirContext,
083                        ISearchParamExtractor theSearchParamExtractor,
084                        PartitionSettings thePartitionSettings) {
085                myFhirContext = theFhirContext;
086                mySearchParamExtractor = theSearchParamExtractor;
087                myPartitionSettings = thePartitionSettings;
088        }
089
090        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)
091        public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) {
092                RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource);
093                List<RuntimeSearchParam> compartmentSps =
094                                ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
095
096                if (compartmentSps.isEmpty() || resourceDef.getName().equals("Group")) {
097                        return provideNonCompartmentMemberTypeResponse(theResource);
098                }
099
100                if (resourceDef.getName().equals("Patient")) {
101                        IIdType idElement = theResource.getIdElement();
102                        if (idElement.getIdPart() == null || idElement.isUuid()) {
103                                throw new MethodNotAllowedException(
104                                                Msg.code(1321)
105                                                                + "Patient resource IDs must be client-assigned in patient compartment mode, or server id strategy must be UUID");
106                        }
107                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idElement.getIdPart());
108                } else {
109                        Optional<String> oCompartmentIdentity = ResourceCompartmentUtil.getResourceCompartment(
110                                        "Patient", theResource, compartmentSps, mySearchParamExtractor);
111
112                        if (oCompartmentIdentity.isPresent()) {
113                                return provideCompartmentMemberInstanceResponse(theRequestDetails, oCompartmentIdentity.get());
114                        } else {
115                                return getPartitionViaPartiallyProcessedReference(theResource)
116                                                // or give up and fail
117                                                .orElseGet(() -> throwNonCompartmentMemberInstanceFailureResponse(theResource));
118                        }
119                }
120        }
121
122        /**
123         * HACK: enable synthea bundles to sneak through with a server-assigned UUID.
124         * If we don't have a simple id for a compartment owner, maybe we're in a bundle during processing
125         * and a reference points to the Patient which has already been processed and assigned a partition.
126         */
127        @Nonnull
128        private Optional<RequestPartitionId> getPartitionViaPartiallyProcessedReference(IBaseResource theResource) {
129                return myFhirContext
130                                .newTerser()
131                                .getCompartmentReferencesForResource(
132                                                "Patient", theResource, new CompartmentSearchParameterModifications())
133                                .map(IBaseReference::getResource)
134                                .filter(Objects::nonNull)
135                                .flatMap(nextResource -> getPartitionIfAssigned(nextResource).stream())
136                                .findFirst();
137        }
138
139        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)
140        public RequestPartitionId identifyForRead(
141                        @Nonnull ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) {
142                List<RuntimeSearchParam> compartmentSps = Collections.emptyList();
143                if (!isEmpty(theReadDetails.getResourceType())) {
144                        RuntimeResourceDefinition resourceDef =
145                                        myFhirContext.getResourceDefinition(theReadDetails.getResourceType());
146                        compartmentSps = ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
147                        if (compartmentSps.isEmpty()) {
148                                return provideNonCompartmentMemberTypeResponse(null);
149                        }
150                }
151
152                //noinspection EnumSwitchStatementWhichMissesCases
153                switch (theReadDetails.getRestOperationType()) {
154                        case READ:
155                        case VREAD:
156                                if ("Patient".equals(theReadDetails.getResourceType())) {
157                                        return provideCompartmentMemberInstanceResponse(
158                                                        theRequestDetails,
159                                                        theReadDetails.getReadResourceId().getIdPart());
160                                }
161                                break;
162                        case SEARCH_TYPE:
163                                SearchParameterMap params = theReadDetails.getSearchParams();
164                                assert params != null;
165                                if ("Patient".equals(theReadDetails.getResourceType())) {
166                                        List<String> idParts = getResourceIdList(params, "_id", "Patient", false);
167                                        if (idParts.size() == 1) {
168                                                return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
169                                        } else {
170                                                return RequestPartitionId.allPartitions();
171                                        }
172                                } else {
173                                        for (RuntimeSearchParam nextCompartmentSp : compartmentSps) {
174                                                List<String> idParts = getResourceIdList(params, nextCompartmentSp.getName(), "Patient", true);
175                                                if (!idParts.isEmpty()) {
176                                                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idParts.get(0));
177                                                }
178                                        }
179                                }
180
181                                break;
182                        case EXTENDED_OPERATION_SERVER:
183                                String extendedOp = theReadDetails.getExtendedOperationName();
184                                if (ProviderConstants.OPERATION_EXPORT.equals(extendedOp)
185                                                || ProviderConstants.OPERATION_EXPORT_POLL_STATUS.equals(extendedOp)) {
186                                        return provideNonPatientSpecificQueryResponse();
187                                }
188                                break;
189                        default:
190                                // nothing
191                }
192
193                if (isBlank(theReadDetails.getResourceType())) {
194                        return provideNonCompartmentMemberTypeResponse(null);
195                }
196
197                // If we couldn't identify a patient ID by the URL, let's try using the
198                // conditional target if we have one
199                if (theReadDetails.getConditionalTargetOrNull() != null) {
200                        return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails);
201                }
202
203                return provideNonPatientSpecificQueryResponse();
204        }
205
206        @SuppressWarnings("SameParameterValue")
207        private List<String> getResourceIdList(
208                        SearchParameterMap theParams, String theParamName, String theResourceType, boolean theExpectOnlyOneBool) {
209                List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName);
210                if (idParamAndList == null) {
211                        return Collections.emptyList();
212                }
213
214                List<String> idParts = new ArrayList<>();
215                idParamAndList.stream().flatMap(Collection::stream).forEach(idParam -> {
216                        if (isNotBlank(idParam.getQueryParameterQualifier())) {
217                                throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName
218                                                + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode");
219                        }
220                        if (idParam instanceof ReferenceParam) {
221                                String chain = ((ReferenceParam) idParam).getChain();
222                                if (chain != null) {
223                                        throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." + chain
224                                                        + " is not supported in patient compartment mode");
225                                }
226                        }
227                        IdType id = new IdType(idParam.getValueAsQueryToken(myFhirContext));
228                        if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) {
229                                idParts.add(id.getIdPart());
230                        }
231                });
232
233                if (theExpectOnlyOneBool && idParts.size() > 1) {
234                        throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName
235                                        + " is not supported in patient compartment mode");
236                }
237
238                return idParts;
239        }
240
241        /**
242         * Return a partition or throw an error for FHIR operations that can not be used with this interceptor
243         */
244        protected RequestPartitionId provideNonPatientSpecificQueryResponse() {
245                return RequestPartitionId.allPartitions();
246        }
247
248        /**
249         * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it
250         * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead.
251         */
252        @Nonnull
253        protected RequestPartitionId provideCompartmentMemberInstanceResponse(
254                        RequestDetails theRequestDetails, String theResourceIdPart) {
255                int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart);
256                return RequestPartitionId.fromPartitionIdAndName(partitionId, theResourceIdPart);
257        }
258
259        /**
260         * Translates an ID (e.g. "ABC") into a compartment ID number.
261         * <p>
262         * The default implementation of this method returns:
263         * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>.
264         * <p>
265         * This logic can be replaced with other logic of your choosing.
266         */
267        @SuppressWarnings("unused")
268        protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) {
269                return Math.abs(theResourceIdPart.hashCode() % 15000);
270        }
271
272        /**
273         * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is
274         * in the patient compartment, but without any search parameter identifying which compartment to search.
275         * <p>
276         * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient
277         * is not identified in the URL.
278         */
279        @Nonnull
280        protected RequestPartitionId throwNonCompartmentMemberInstanceFailureResponse(IBaseResource theResource) {
281                throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type "
282                                + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment");
283        }
284
285        /**
286         * Return a compartment ID (or throw an exception) when storing/reading resource types that
287         * are not in the patient compartment (e.g. ValueSet).
288         */
289        @SuppressWarnings("unused")
290        @Nonnull
291        protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) {
292                return myPartitionSettings.getDefaultRequestPartitionId();
293        }
294}