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}