001/*-
002 * #%L
003 * HAPI FHIR - Master Data Management
004 * %%
005 * Copyright (C) 2014 - 2024 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.mdm.interceptor;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.api.Hook;
025import ca.uhn.fhir.interceptor.api.Pointcut;
026import ca.uhn.fhir.interceptor.model.RequestPartitionId;
027import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
028import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
029import ca.uhn.fhir.jpa.api.model.DeleteConflictList;
030import ca.uhn.fhir.jpa.api.svc.IDeleteExpungeSvc;
031import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
032import ca.uhn.fhir.jpa.api.svc.IMdmClearHelperSvc;
033import ca.uhn.fhir.jpa.dao.expunge.IExpungeEverythingService;
034import ca.uhn.fhir.mdm.api.IMdmLink;
035import ca.uhn.fhir.mdm.api.IMdmLinkUpdaterSvc;
036import ca.uhn.fhir.mdm.api.IMdmSettings;
037import ca.uhn.fhir.mdm.api.IMdmSubmitSvc;
038import ca.uhn.fhir.mdm.api.MdmConstants;
039import ca.uhn.fhir.mdm.api.MdmMatchResultEnum;
040import ca.uhn.fhir.mdm.dao.IMdmLinkDao;
041import ca.uhn.fhir.mdm.model.CanonicalEID;
042import ca.uhn.fhir.mdm.model.MdmCreateOrUpdateParams;
043import ca.uhn.fhir.mdm.model.MdmTransactionContext;
044import ca.uhn.fhir.mdm.svc.MdmLinkDeleteSvc;
045import ca.uhn.fhir.mdm.util.EIDHelper;
046import ca.uhn.fhir.mdm.util.MdmResourceUtil;
047import ca.uhn.fhir.rest.api.server.RequestDetails;
048import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
049import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
050import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
051import ca.uhn.fhir.rest.server.TransactionLogMessages;
052import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
053import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
054import org.hl7.fhir.instance.model.api.IAnyResource;
055import org.hl7.fhir.instance.model.api.IBaseResource;
056import org.hl7.fhir.instance.model.api.IIdType;
057import org.slf4j.Logger;
058import org.slf4j.LoggerFactory;
059import org.springframework.beans.factory.annotation.Autowired;
060import org.springframework.stereotype.Service;
061
062import java.util.ArrayList;
063import java.util.Collections;
064import java.util.HashSet;
065import java.util.List;
066import java.util.Map;
067import java.util.Set;
068import java.util.concurrent.atomic.AtomicInteger;
069import java.util.stream.Collectors;
070
071import static ca.uhn.fhir.mdm.api.MdmMatchResultEnum.MATCH;
072import static ca.uhn.fhir.mdm.api.MdmMatchResultEnum.NO_MATCH;
073import static ca.uhn.fhir.mdm.api.MdmMatchResultEnum.POSSIBLE_MATCH;
074
075@SuppressWarnings("rawtypes")
076@Service
077public class MdmStorageInterceptor implements IMdmStorageInterceptor {
078
079        private static final String GOLDEN_RESOURCES_TO_DELETE = "GR_TO_DELETE";
080
081        private static final Logger ourLog = LoggerFactory.getLogger(MdmStorageInterceptor.class);
082
083        // Used to bypass trying to remove mdm links associated to a resource when running mdm-clear batch job, which
084        // deletes all links beforehand, and impacts performance for no action
085        private static final ThreadLocal<Boolean> ourLinksDeletedBeforehand = ThreadLocal.withInitial(() -> Boolean.FALSE);
086
087        @Autowired
088        private IMdmClearHelperSvc<? extends IResourcePersistentId<?>> myIMdmClearHelperSvc;
089
090        @Autowired
091        private IExpungeEverythingService myExpungeEverythingService;
092
093        @Autowired
094        private MdmLinkDeleteSvc myMdmLinkDeleteSvc;
095
096        @Autowired
097        private FhirContext myFhirContext;
098
099        @Autowired
100        private EIDHelper myEIDHelper;
101
102        @Autowired
103        private IMdmSettings myMdmSettings;
104
105        @Autowired
106        private IIdHelperService myIdHelperSvc;
107
108        @Autowired
109        private IMdmLinkDao myMdmLinkDao;
110
111        @Autowired
112        private IMdmSubmitSvc myMdmSubmitSvc;
113
114        @Autowired
115        private DaoRegistry myDaoRegistry;
116
117        @Autowired
118        private IMdmLinkUpdaterSvc mdmLinkUpdaterSvc;
119
120        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
121        public void blockManualResourceManipulationOnCreate(
122                        IBaseResource theBaseResource,
123                        RequestDetails theRequestDetails,
124                        ServletRequestDetails theServletRequestDetails) {
125                ourLog.debug(
126                                "Starting pre-storage resource created hook for {}, {}, {}",
127                                theBaseResource,
128                                theRequestDetails,
129                                theServletRequestDetails);
130                if (theBaseResource == null) {
131                        ourLog.warn("Attempting to block golden resource manipulation on a null resource");
132                        return;
133                }
134
135                // If running in single EID mode, forbid multiple eids.
136                if (myMdmSettings.isPreventMultipleEids()) {
137                        ourLog.debug("Forbidding multiple EIDs on {}", theBaseResource);
138                        forbidIfHasMultipleEids(theBaseResource);
139                }
140
141                // TODO GGG MDM find a better way to identify internal calls?
142                if (isInternalRequest(theRequestDetails)) {
143                        ourLog.debug("Internal request - completed processing");
144                        return;
145                }
146
147                forbidIfMdmManagedTagIsPresent(theBaseResource);
148        }
149
150        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
151        public void blockManualGoldenResourceManipulationOnUpdate(
152                        IBaseResource theOldResource,
153                        IBaseResource theUpdatedResource,
154                        RequestDetails theRequestDetails,
155                        ServletRequestDetails theServletRequestDetails) {
156                ourLog.debug(
157                                "Starting pre-storage resource updated hook for {}, {}, {}, {}",
158                                theOldResource,
159                                theUpdatedResource,
160                                theRequestDetails,
161                                theServletRequestDetails);
162
163                if (theUpdatedResource == null) {
164                        ourLog.warn("Attempting to block golden resource manipulation on a null resource");
165                        return;
166                }
167
168                // If running in single EID mode, forbid multiple eids.
169                if (myMdmSettings.isPreventMultipleEids()) {
170                        ourLog.debug("Forbidding multiple EIDs on {}", theUpdatedResource);
171                        forbidIfHasMultipleEids(theUpdatedResource);
172                }
173
174                if (MdmResourceUtil.isGoldenRecordRedirected(theUpdatedResource)) {
175                        ourLog.debug(
176                                        "Deleting MDM links to deactivated Golden resource {}",
177                                        theUpdatedResource.getIdElement().toUnqualifiedVersionless());
178                        int deleted = myMdmLinkDeleteSvc.deleteNonRedirectWithAnyReferenceTo(theUpdatedResource);
179                        if (deleted > 0) {
180                                ourLog.debug("Deleted {} MDM links", deleted);
181                        }
182                }
183
184                if (isInternalRequest(theRequestDetails)) {
185                        ourLog.debug("Internal request - completed processing");
186                        return;
187                }
188
189                if (theOldResource != null) {
190                        forbidIfMdmManagedTagIsPresent(theOldResource);
191                        forbidModifyingMdmTag(theUpdatedResource, theOldResource);
192                }
193
194                if (myMdmSettings.isPreventEidUpdates()) {
195                        forbidIfModifyingExternalEidOnTarget(theUpdatedResource, theOldResource);
196                }
197        }
198
199        @Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_DELETED)
200        public void deletePostCommit(
201                        RequestDetails theRequest, IBaseResource theResource, TransactionDetails theTransactionDetails) {
202                Set<IResourcePersistentId> goldenResourceIds = theTransactionDetails.getUserData(GOLDEN_RESOURCES_TO_DELETE);
203
204                if (goldenResourceIds != null) {
205                        for (IResourcePersistentId goldenPid : goldenResourceIds) {
206                                if (!theTransactionDetails.getDeletedResourceIds().contains(goldenPid)) {
207                                        IFhirResourceDao<?> dao = myDaoRegistry.getResourceDao(theResource);
208                                        deleteGoldenResource(goldenPid, dao, theRequest);
209                                        /*
210                                         * We will add the removed id to the deleted list so that
211                                         * the deletedResourceId list is accurte for what has been
212                                         * deleted.
213                                         *
214                                         * This benefits other interceptor writers who might want
215                                         * to do their own resource deletion on this same pre-commit
216                                         * hook (and wouldn't be aware if we did this deletion already).
217                                         */
218                                        theTransactionDetails.addDeletedResourceId(goldenPid);
219                                }
220                        }
221                        theTransactionDetails.putUserData(GOLDEN_RESOURCES_TO_DELETE, null);
222                }
223        }
224
225        @SuppressWarnings("unchecked")
226        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_DELETED)
227        public void deleteMdmLinks(
228                        RequestDetails theRequest, IBaseResource theResource, TransactionDetails theTransactionDetails) {
229                if (ourLinksDeletedBeforehand.get()) {
230                        return;
231                }
232
233                if (myMdmSettings.isSupportedMdmType(myFhirContext.getResourceType(theResource))) {
234                        IIdType sourceId = theResource.getIdElement().toVersionless();
235                        IResourcePersistentId sourcePid =
236                                        myIdHelperSvc.getPidOrThrowException(RequestPartitionId.allPartitions(), sourceId);
237                        List<IMdmLink> allLinks =
238                                        myMdmLinkDao.findLinksAssociatedWithGoldenResourceOfSourceResourceExcludingNoMatch(sourcePid);
239
240                        Map<MdmMatchResultEnum, List<IMdmLink>> linksByMatchResult =
241                                        allLinks.stream().collect(Collectors.groupingBy(IMdmLink::getMatchResult));
242                        List<IMdmLink> matches =
243                                        linksByMatchResult.containsKey(MATCH) ? linksByMatchResult.get(MATCH) : new ArrayList<>();
244                        List<IMdmLink> possibleMatches = linksByMatchResult.containsKey(POSSIBLE_MATCH)
245                                        ? linksByMatchResult.get(POSSIBLE_MATCH)
246                                        : new ArrayList<>();
247
248                        if (isDeletingLastMatchedSourceResource(sourcePid, matches)) {
249                                /*
250                                 * We are attempting to delete the only source resource left linked to the golden resource.
251                                 * In this case, we'll clean up remaining links and mark the orphaned
252                                 * golden resource for deletion, which we'll do in STORAGE_PRECOMMIT_RESOURCE_DELETED
253                                 */
254                                IResourcePersistentId goldenPid = extractGoldenPid(theResource, matches.get(0));
255                                if (!theTransactionDetails.getDeletedResourceIds().contains(goldenPid)) {
256                                        IFhirResourceDao<?> dao = myDaoRegistry.getResourceDao(theResource);
257
258                                        cleanUpPossibleMatches(possibleMatches, dao, goldenPid, theRequest);
259
260                                        IAnyResource goldenResource = (IAnyResource) dao.readByPid(goldenPid);
261                                        myMdmLinkDeleteSvc.deleteWithAnyReferenceTo(goldenResource);
262
263                                        /*
264                                         * Mark the golden resource for deletion.
265                                         * We won't do it yet, because there might be additional deletes coming
266                                         * that include this exact golden resource
267                                         * (eg, if delete is done by a filter and multiple delete is enabled)
268                                         */
269                                        Set<IResourcePersistentId> goldenIdsToDelete =
270                                                        theTransactionDetails.getUserData(GOLDEN_RESOURCES_TO_DELETE);
271                                        if (goldenIdsToDelete == null) {
272                                                goldenIdsToDelete = new HashSet<>();
273                                        }
274                                        goldenIdsToDelete.add(goldenPid);
275                                        theTransactionDetails.putUserData(GOLDEN_RESOURCES_TO_DELETE, goldenIdsToDelete);
276                                }
277                        }
278                        myMdmLinkDeleteSvc.deleteWithAnyReferenceTo(theResource);
279                }
280        }
281
282        @SuppressWarnings("rawtypes")
283        private void deleteGoldenResource(
284                        IResourcePersistentId goldenPid, IFhirResourceDao<?> theDao, RequestDetails theRequest) {
285                setLinksDeletedBeforehand();
286
287                if (myMdmSettings.isAutoExpungeGoldenResources()) {
288                        int numDeleted = deleteExpungeGoldenResource(goldenPid);
289                        if (numDeleted > 0) {
290                                ourLog.info("Removed {} golden resource(s).", numDeleted);
291                        }
292                } else {
293                        String url = theRequest == null ? "" : theRequest.getCompleteUrl();
294                        theDao.deletePidList(
295                                        url,
296                                        Collections.singleton(goldenPid),
297                                        new DeleteConflictList(),
298                                        theRequest,
299                                        new TransactionDetails());
300                }
301
302                resetLinksDeletedBeforehand();
303        }
304
305        /**
306         *  Clean up possible matches associated with a GR if they are the only link left
307         *  since they are no longer "real matches"
308         *  Possible match resources are resubmitted for matching
309         */
310        private void cleanUpPossibleMatches(
311                        List<IMdmLink> possibleMatches,
312                        IFhirResourceDao<?> theDao,
313                        IResourcePersistentId theGoldenPid,
314                        RequestDetails theRequestDetails) {
315                IAnyResource goldenResource = (IAnyResource) theDao.readByPid(theGoldenPid);
316                for (IMdmLink possibleMatch : possibleMatches) {
317                        if (possibleMatch.getGoldenResourcePersistenceId().equals(theGoldenPid)) {
318                                IBaseResource sourceResource = theDao.readByPid(possibleMatch.getSourcePersistenceId());
319                                MdmCreateOrUpdateParams params = new MdmCreateOrUpdateParams();
320                                params.setGoldenResource(goldenResource);
321                                params.setSourceResource((IAnyResource) sourceResource);
322                                params.setMatchResult(NO_MATCH);
323                                MdmTransactionContext mdmContext =
324                                                createMdmContext(MdmTransactionContext.OperationType.UPDATE_LINK, sourceResource.fhirType());
325                                params.setMdmContext(mdmContext);
326                                params.setRequestDetails(theRequestDetails);
327
328                                mdmLinkUpdaterSvc.updateLink(params);
329                        }
330                }
331        }
332
333        private IResourcePersistentId extractGoldenPid(IBaseResource theResource, IMdmLink theMdmLink) {
334                IResourcePersistentId goldenPid = theMdmLink.getGoldenResourcePersistenceId();
335                goldenPid = myIdHelperSvc.newPidFromStringIdAndResourceName(goldenPid.toString(), theResource.fhirType());
336                return goldenPid;
337        }
338
339        private boolean isDeletingLastMatchedSourceResource(IResourcePersistentId theSourcePid, List<IMdmLink> theMatches) {
340                return theMatches.size() == 1
341                                && theMatches.get(0).getSourcePersistenceId().equals(theSourcePid);
342        }
343
344        private MdmTransactionContext createMdmContext(
345                        MdmTransactionContext.OperationType theOperation, String theResourceType) {
346                TransactionLogMessages transactionLogMessages = TransactionLogMessages.createNew();
347                MdmTransactionContext retVal = new MdmTransactionContext(transactionLogMessages, theOperation);
348                retVal.setResourceType(theResourceType);
349                return retVal;
350        }
351
352        @SuppressWarnings("unchecked")
353        private int deleteExpungeGoldenResource(IResourcePersistentId theGoldenPid) {
354                IDeleteExpungeSvc deleteExpungeSvc = myIMdmClearHelperSvc.getDeleteExpungeSvc();
355                return deleteExpungeSvc.deleteExpunge(new ArrayList<>(Collections.singleton(theGoldenPid)), false, null);
356        }
357
358        private void forbidIfModifyingExternalEidOnTarget(IBaseResource theNewResource, IBaseResource theOldResource) {
359                List<CanonicalEID> newExternalEids = Collections.emptyList();
360                List<CanonicalEID> oldExternalEids = Collections.emptyList();
361                if (theNewResource != null) {
362                        newExternalEids = myEIDHelper.getExternalEid(theNewResource);
363                }
364                if (theOldResource != null) {
365                        oldExternalEids = myEIDHelper.getExternalEid(theOldResource);
366                }
367
368                if (oldExternalEids.isEmpty()) {
369                        return;
370                }
371
372                if (!myEIDHelper.eidMatchExists(newExternalEids, oldExternalEids)) {
373                        throwBlockEidChange();
374                }
375        }
376
377        private void throwBlockEidChange() {
378                throw new ForbiddenOperationException(
379                                Msg.code(763) + "While running with EID updates disabled, EIDs may not be updated on source resources");
380        }
381
382        /*
383         * Will throw a forbidden error if a request attempts to add/remove the MDM tag on a Resource.
384         */
385        private void forbidModifyingMdmTag(IBaseResource theNewResource, IBaseResource theOldResource) {
386                if (MdmResourceUtil.isMdmManaged(theNewResource) != MdmResourceUtil.isMdmManaged(theOldResource)) {
387                        throwBlockMdmManagedTagChange();
388                }
389        }
390
391        private void forbidIfHasMultipleEids(IBaseResource theResource) {
392                String resourceType = extractResourceType(theResource);
393                if (myMdmSettings.isSupportedMdmType(resourceType)) {
394                        if (myEIDHelper.getExternalEid(theResource).size() > 1) {
395                                throwBlockMultipleEids();
396                        }
397                }
398        }
399
400        /*
401         * We assume that if we have RequestDetails, then this was an HTTP request and not an internal one.
402         */
403        private boolean isInternalRequest(RequestDetails theRequestDetails) {
404                return theRequestDetails == null || theRequestDetails instanceof SystemRequestDetails;
405        }
406
407        private void forbidIfMdmManagedTagIsPresent(IBaseResource theResource) {
408                if (theResource == null) {
409                        ourLog.warn("Attempting to forbid MDM on a null resource");
410                        return;
411                }
412
413                if (MdmResourceUtil.isMdmManaged(theResource)) {
414                        throwModificationBlockedByMdm();
415                }
416                if (MdmResourceUtil.hasGoldenRecordSystemTag(theResource)) {
417                        throwModificationBlockedByMdm();
418                }
419        }
420
421        private void throwBlockMdmManagedTagChange() {
422                throw new ForbiddenOperationException(Msg.code(764) + "The " + MdmConstants.CODE_HAPI_MDM_MANAGED
423                                + " tag on a resource may not be changed once created.");
424        }
425
426        private void throwModificationBlockedByMdm() {
427                throw new ForbiddenOperationException(Msg.code(765)
428                                + "Cannot create or modify Resources that are managed by MDM. This resource contains a tag with one of these systems: "
429                                + MdmConstants.SYSTEM_GOLDEN_RECORD_STATUS + " or " + MdmConstants.SYSTEM_MDM_MANAGED);
430        }
431
432        private void throwBlockMultipleEids() {
433                throw new ForbiddenOperationException(Msg.code(766)
434                                + "While running with multiple EIDs disabled, source resources may have at most one EID.");
435        }
436
437        private String extractResourceType(IBaseResource theResource) {
438                return myFhirContext.getResourceType(theResource);
439        }
440
441        @Hook(Pointcut.STORAGE_PRESTORAGE_EXPUNGE_EVERYTHING)
442        public void expungeAllMdmLinks(AtomicInteger theCounter) {
443                ourLog.debug("Expunging all MdmLink records");
444                theCounter.addAndGet(myExpungeEverythingService.expungeEverythingMdmLinks());
445        }
446
447        @Hook(Pointcut.STORAGE_PRESTORAGE_EXPUNGE_RESOURCE)
448        public void expungeAllMatchedMdmLinks(AtomicInteger theCounter, IBaseResource theResource) {
449                ourLog.debug("Expunging MdmLink records with reference to {}", theResource.getIdElement());
450                theCounter.addAndGet(myMdmLinkDeleteSvc.deleteWithAnyReferenceTo(theResource));
451        }
452
453        public static void setLinksDeletedBeforehand() {
454                ourLinksDeletedBeforehand.set(Boolean.TRUE);
455        }
456
457        public static void resetLinksDeletedBeforehand() {
458                ourLinksDeletedBeforehand.remove();
459        }
460}