All Downloads are FREE. Search and download functionalities are using the official Maven repository.

ca.uhn.fhir.mdm.interceptor.MdmStorageInterceptor Maven / Gradle / Ivy

/*-
 * #%L
 * HAPI FHIR - Master Data Management
 * %%
 * Copyright (C) 2014 - 2024 Smile CDR, Inc.
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package ca.uhn.fhir.mdm.interceptor;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
import ca.uhn.fhir.jpa.api.model.DeleteConflictList;
import ca.uhn.fhir.jpa.api.svc.IDeleteExpungeSvc;
import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
import ca.uhn.fhir.jpa.api.svc.IMdmClearHelperSvc;
import ca.uhn.fhir.jpa.dao.expunge.IExpungeEverythingService;
import ca.uhn.fhir.mdm.api.IMdmLink;
import ca.uhn.fhir.mdm.api.IMdmLinkUpdaterSvc;
import ca.uhn.fhir.mdm.api.IMdmSettings;
import ca.uhn.fhir.mdm.api.IMdmSubmitSvc;
import ca.uhn.fhir.mdm.api.MdmConstants;
import ca.uhn.fhir.mdm.api.MdmMatchResultEnum;
import ca.uhn.fhir.mdm.dao.IMdmLinkDao;
import ca.uhn.fhir.mdm.model.CanonicalEID;
import ca.uhn.fhir.mdm.model.MdmCreateOrUpdateParams;
import ca.uhn.fhir.mdm.model.MdmTransactionContext;
import ca.uhn.fhir.mdm.svc.MdmLinkDeleteSvc;
import ca.uhn.fhir.mdm.util.EIDHelper;
import ca.uhn.fhir.mdm.util.MdmResourceUtil;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
import ca.uhn.fhir.rest.server.TransactionLogMessages;
import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import org.hl7.fhir.instance.model.api.IAnyResource;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

import static ca.uhn.fhir.mdm.api.MdmMatchResultEnum.MATCH;
import static ca.uhn.fhir.mdm.api.MdmMatchResultEnum.NO_MATCH;
import static ca.uhn.fhir.mdm.api.MdmMatchResultEnum.POSSIBLE_MATCH;

@SuppressWarnings("rawtypes")
@Service
public class MdmStorageInterceptor implements IMdmStorageInterceptor {

	private static final String GOLDEN_RESOURCES_TO_DELETE = "GR_TO_DELETE";

	private static final Logger ourLog = LoggerFactory.getLogger(MdmStorageInterceptor.class);

	// Used to bypass trying to remove mdm links associated to a resource when running mdm-clear batch job, which
	// deletes all links beforehand, and impacts performance for no action
	private static final ThreadLocal ourLinksDeletedBeforehand = ThreadLocal.withInitial(() -> Boolean.FALSE);

	@Autowired
	private IMdmClearHelperSvc> myIMdmClearHelperSvc;

	@Autowired
	private IExpungeEverythingService myExpungeEverythingService;

	@Autowired
	private MdmLinkDeleteSvc myMdmLinkDeleteSvc;

	@Autowired
	private FhirContext myFhirContext;

	@Autowired
	private EIDHelper myEIDHelper;

	@Autowired
	private IMdmSettings myMdmSettings;

	@Autowired
	private IIdHelperService myIdHelperSvc;

	@Autowired
	private IMdmLinkDao myMdmLinkDao;

	@Autowired
	private IMdmSubmitSvc myMdmSubmitSvc;

	@Autowired
	private DaoRegistry myDaoRegistry;

	@Autowired
	private IMdmLinkUpdaterSvc mdmLinkUpdaterSvc;

	@Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
	public void blockManualResourceManipulationOnCreate(
			IBaseResource theBaseResource,
			RequestDetails theRequestDetails,
			ServletRequestDetails theServletRequestDetails) {
		ourLog.debug(
				"Starting pre-storage resource created hook for {}, {}, {}",
				theBaseResource,
				theRequestDetails,
				theServletRequestDetails);
		if (theBaseResource == null) {
			ourLog.warn("Attempting to block golden resource manipulation on a null resource");
			return;
		}

		// If running in single EID mode, forbid multiple eids.
		if (myMdmSettings.isPreventMultipleEids()) {
			ourLog.debug("Forbidding multiple EIDs on {}", theBaseResource);
			forbidIfHasMultipleEids(theBaseResource);
		}

		// TODO GGG MDM find a better way to identify internal calls?
		if (isInternalRequest(theRequestDetails)) {
			ourLog.debug("Internal request - completed processing");
			return;
		}

		forbidIfMdmManagedTagIsPresent(theBaseResource);
	}

	@Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
	public void blockManualGoldenResourceManipulationOnUpdate(
			IBaseResource theOldResource,
			IBaseResource theUpdatedResource,
			RequestDetails theRequestDetails,
			ServletRequestDetails theServletRequestDetails) {
		ourLog.debug(
				"Starting pre-storage resource updated hook for {}, {}, {}, {}",
				theOldResource,
				theUpdatedResource,
				theRequestDetails,
				theServletRequestDetails);

		if (theUpdatedResource == null) {
			ourLog.warn("Attempting to block golden resource manipulation on a null resource");
			return;
		}

		// If running in single EID mode, forbid multiple eids.
		if (myMdmSettings.isPreventMultipleEids()) {
			ourLog.debug("Forbidding multiple EIDs on {}", theUpdatedResource);
			forbidIfHasMultipleEids(theUpdatedResource);
		}

		if (MdmResourceUtil.isGoldenRecordRedirected(theUpdatedResource)) {
			ourLog.debug(
					"Deleting MDM links to deactivated Golden resource {}",
					theUpdatedResource.getIdElement().toUnqualifiedVersionless());
			int deleted = myMdmLinkDeleteSvc.deleteNonRedirectWithAnyReferenceTo(theUpdatedResource);
			if (deleted > 0) {
				ourLog.debug("Deleted {} MDM links", deleted);
			}
		}

		if (isInternalRequest(theRequestDetails)) {
			ourLog.debug("Internal request - completed processing");
			return;
		}

		if (theOldResource != null) {
			forbidIfMdmManagedTagIsPresent(theOldResource);
			forbidModifyingMdmTag(theUpdatedResource, theOldResource);
		}

		if (myMdmSettings.isPreventEidUpdates()) {
			forbidIfModifyingExternalEidOnTarget(theUpdatedResource, theOldResource);
		}
	}

	@Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_DELETED)
	public void deletePostCommit(
			RequestDetails theRequest, IBaseResource theResource, TransactionDetails theTransactionDetails) {
		Set goldenResourceIds = theTransactionDetails.getUserData(GOLDEN_RESOURCES_TO_DELETE);

		if (goldenResourceIds != null) {
			for (IResourcePersistentId goldenPid : goldenResourceIds) {
				if (!theTransactionDetails.getDeletedResourceIds().contains(goldenPid)) {
					IFhirResourceDao dao = myDaoRegistry.getResourceDao(theResource);
					deleteGoldenResource(goldenPid, dao, theRequest);
					/*
					 * We will add the removed id to the deleted list so that
					 * the deletedResourceId list is accurte for what has been
					 * deleted.
					 *
					 * This benefits other interceptor writers who might want
					 * to do their own resource deletion on this same pre-commit
					 * hook (and wouldn't be aware if we did this deletion already).
					 */
					theTransactionDetails.addDeletedResourceId(goldenPid);
				}
			}
			theTransactionDetails.putUserData(GOLDEN_RESOURCES_TO_DELETE, null);
		}
	}

	@SuppressWarnings("unchecked")
	@Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_DELETED)
	public void deleteMdmLinks(
			RequestDetails theRequest, IBaseResource theResource, TransactionDetails theTransactionDetails) {
		if (ourLinksDeletedBeforehand.get()) {
			return;
		}

		if (myMdmSettings.isSupportedMdmType(myFhirContext.getResourceType(theResource))) {
			IIdType sourceId = theResource.getIdElement().toVersionless();
			IResourcePersistentId sourcePid =
					myIdHelperSvc.getPidOrThrowException(RequestPartitionId.allPartitions(), sourceId);
			List allLinks =
					myMdmLinkDao.findLinksAssociatedWithGoldenResourceOfSourceResourceExcludingNoMatch(sourcePid);

			Map> linksByMatchResult =
					allLinks.stream().collect(Collectors.groupingBy(IMdmLink::getMatchResult));
			List matches =
					linksByMatchResult.containsKey(MATCH) ? linksByMatchResult.get(MATCH) : new ArrayList<>();
			List possibleMatches = linksByMatchResult.containsKey(POSSIBLE_MATCH)
					? linksByMatchResult.get(POSSIBLE_MATCH)
					: new ArrayList<>();

			if (isDeletingLastMatchedSourceResource(sourcePid, matches)) {
				/*
				 * We are attempting to delete the only source resource left linked to the golden resource.
				 * In this case, we'll clean up remaining links and mark the orphaned
				 * golden resource for deletion, which we'll do in STORAGE_PRECOMMIT_RESOURCE_DELETED
				 */
				IResourcePersistentId goldenPid = extractGoldenPid(theResource, matches.get(0));
				if (!theTransactionDetails.getDeletedResourceIds().contains(goldenPid)) {
					IFhirResourceDao dao = myDaoRegistry.getResourceDao(theResource);

					cleanUpPossibleMatches(possibleMatches, dao, goldenPid, theRequest);

					IAnyResource goldenResource = (IAnyResource) dao.readByPid(goldenPid);
					myMdmLinkDeleteSvc.deleteWithAnyReferenceTo(goldenResource);

					/*
					 * Mark the golden resource for deletion.
					 * We won't do it yet, because there might be additional deletes coming
					 * that include this exact golden resource
					 * (eg, if delete is done by a filter and multiple delete is enabled)
					 */
					Set goldenIdsToDelete =
							theTransactionDetails.getUserData(GOLDEN_RESOURCES_TO_DELETE);
					if (goldenIdsToDelete == null) {
						goldenIdsToDelete = new HashSet<>();
					}
					goldenIdsToDelete.add(goldenPid);
					theTransactionDetails.putUserData(GOLDEN_RESOURCES_TO_DELETE, goldenIdsToDelete);
				}
			}
			myMdmLinkDeleteSvc.deleteWithAnyReferenceTo(theResource);
		}
	}

	@SuppressWarnings("rawtypes")
	private void deleteGoldenResource(
			IResourcePersistentId goldenPid, IFhirResourceDao theDao, RequestDetails theRequest) {
		setLinksDeletedBeforehand();

		if (myMdmSettings.isAutoExpungeGoldenResources()) {
			int numDeleted = deleteExpungeGoldenResource(goldenPid);
			if (numDeleted > 0) {
				ourLog.info("Removed {} golden resource(s).", numDeleted);
			}
		} else {
			String url = theRequest == null ? "" : theRequest.getCompleteUrl();
			theDao.deletePidList(
					url,
					Collections.singleton(goldenPid),
					new DeleteConflictList(),
					theRequest,
					new TransactionDetails());
		}

		resetLinksDeletedBeforehand();
	}

	/**
	 *  Clean up possible matches associated with a GR if they are the only link left
	 *  since they are no longer "real matches"
	 *  Possible match resources are resubmitted for matching
	 */
	private void cleanUpPossibleMatches(
			List possibleMatches,
			IFhirResourceDao theDao,
			IResourcePersistentId theGoldenPid,
			RequestDetails theRequestDetails) {
		IAnyResource goldenResource = (IAnyResource) theDao.readByPid(theGoldenPid);
		for (IMdmLink possibleMatch : possibleMatches) {
			if (possibleMatch.getGoldenResourcePersistenceId().equals(theGoldenPid)) {
				IBaseResource sourceResource = theDao.readByPid(possibleMatch.getSourcePersistenceId());
				MdmCreateOrUpdateParams params = new MdmCreateOrUpdateParams();
				params.setGoldenResource(goldenResource);
				params.setSourceResource((IAnyResource) sourceResource);
				params.setMatchResult(NO_MATCH);
				MdmTransactionContext mdmContext =
						createMdmContext(MdmTransactionContext.OperationType.UPDATE_LINK, sourceResource.fhirType());
				params.setMdmContext(mdmContext);
				params.setRequestDetails(theRequestDetails);

				mdmLinkUpdaterSvc.updateLink(params);
			}
		}
	}

	private IResourcePersistentId extractGoldenPid(IBaseResource theResource, IMdmLink theMdmLink) {
		IResourcePersistentId goldenPid = theMdmLink.getGoldenResourcePersistenceId();
		goldenPid = myIdHelperSvc.newPidFromStringIdAndResourceName(goldenPid.toString(), theResource.fhirType());
		return goldenPid;
	}

	private boolean isDeletingLastMatchedSourceResource(IResourcePersistentId theSourcePid, List theMatches) {
		return theMatches.size() == 1
				&& theMatches.get(0).getSourcePersistenceId().equals(theSourcePid);
	}

	private MdmTransactionContext createMdmContext(
			MdmTransactionContext.OperationType theOperation, String theResourceType) {
		TransactionLogMessages transactionLogMessages = TransactionLogMessages.createNew();
		MdmTransactionContext retVal = new MdmTransactionContext(transactionLogMessages, theOperation);
		retVal.setResourceType(theResourceType);
		return retVal;
	}

	@SuppressWarnings("unchecked")
	private int deleteExpungeGoldenResource(IResourcePersistentId theGoldenPid) {
		IDeleteExpungeSvc deleteExpungeSvc = myIMdmClearHelperSvc.getDeleteExpungeSvc();
		return deleteExpungeSvc.deleteExpunge(new ArrayList<>(Collections.singleton(theGoldenPid)), false, null);
	}

	private void forbidIfModifyingExternalEidOnTarget(IBaseResource theNewResource, IBaseResource theOldResource) {
		List newExternalEids = Collections.emptyList();
		List oldExternalEids = Collections.emptyList();
		if (theNewResource != null) {
			newExternalEids = myEIDHelper.getExternalEid(theNewResource);
		}
		if (theOldResource != null) {
			oldExternalEids = myEIDHelper.getExternalEid(theOldResource);
		}

		if (oldExternalEids.isEmpty()) {
			return;
		}

		if (!myEIDHelper.eidMatchExists(newExternalEids, oldExternalEids)) {
			throwBlockEidChange();
		}
	}

	private void throwBlockEidChange() {
		throw new ForbiddenOperationException(
				Msg.code(763) + "While running with EID updates disabled, EIDs may not be updated on source resources");
	}

	/*
	 * Will throw a forbidden error if a request attempts to add/remove the MDM tag on a Resource.
	 */
	private void forbidModifyingMdmTag(IBaseResource theNewResource, IBaseResource theOldResource) {
		if (MdmResourceUtil.isMdmManaged(theNewResource) != MdmResourceUtil.isMdmManaged(theOldResource)) {
			throwBlockMdmManagedTagChange();
		}
	}

	private void forbidIfHasMultipleEids(IBaseResource theResource) {
		String resourceType = extractResourceType(theResource);
		if (myMdmSettings.isSupportedMdmType(resourceType)) {
			if (myEIDHelper.getExternalEid(theResource).size() > 1) {
				throwBlockMultipleEids();
			}
		}
	}

	/*
	 * We assume that if we have RequestDetails, then this was an HTTP request and not an internal one.
	 */
	private boolean isInternalRequest(RequestDetails theRequestDetails) {
		return theRequestDetails == null || theRequestDetails instanceof SystemRequestDetails;
	}

	private void forbidIfMdmManagedTagIsPresent(IBaseResource theResource) {
		if (theResource == null) {
			ourLog.warn("Attempting to forbid MDM on a null resource");
			return;
		}

		if (MdmResourceUtil.isMdmManaged(theResource)) {
			throwModificationBlockedByMdm();
		}
		if (MdmResourceUtil.hasGoldenRecordSystemTag(theResource)) {
			throwModificationBlockedByMdm();
		}
	}

	private void throwBlockMdmManagedTagChange() {
		throw new ForbiddenOperationException(Msg.code(764) + "The " + MdmConstants.CODE_HAPI_MDM_MANAGED
				+ " tag on a resource may not be changed once created.");
	}

	private void throwModificationBlockedByMdm() {
		throw new ForbiddenOperationException(Msg.code(765)
				+ "Cannot create or modify Resources that are managed by MDM. This resource contains a tag with one of these systems: "
				+ MdmConstants.SYSTEM_GOLDEN_RECORD_STATUS + " or " + MdmConstants.SYSTEM_MDM_MANAGED);
	}

	private void throwBlockMultipleEids() {
		throw new ForbiddenOperationException(Msg.code(766)
				+ "While running with multiple EIDs disabled, source resources may have at most one EID.");
	}

	private String extractResourceType(IBaseResource theResource) {
		return myFhirContext.getResourceType(theResource);
	}

	@Hook(Pointcut.STORAGE_PRESTORAGE_EXPUNGE_EVERYTHING)
	public void expungeAllMdmLinks(AtomicInteger theCounter) {
		ourLog.debug("Expunging all MdmLink records");
		theCounter.addAndGet(myExpungeEverythingService.expungeEverythingMdmLinks());
	}

	@Hook(Pointcut.STORAGE_PRESTORAGE_EXPUNGE_RESOURCE)
	public void expungeAllMatchedMdmLinks(AtomicInteger theCounter, IBaseResource theResource) {
		ourLog.debug("Expunging MdmLink records with reference to {}", theResource.getIdElement());
		theCounter.addAndGet(myMdmLinkDeleteSvc.deleteWithAnyReferenceTo(theResource));
	}

	public static void setLinksDeletedBeforehand() {
		ourLinksDeletedBeforehand.set(Boolean.TRUE);
	}

	public static void resetLinksDeletedBeforehand() {
		ourLinksDeletedBeforehand.remove();
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy