5558 disallow resource updates crossing patient compartment (#5562)

* Improve readability

* Fix warnings

* Add interceptor, test doc and changelog. Also, extract common used function to utility class and add tests.

* spotless

* Fix changelog link

* Fix changelog fragment warnings

* Fix changelog link

* Fix documentation link

* Implement review comments

* Implement review comments

* Keep old exception code for tests

* Revert refactoring which lost difference between NonCompartmentMemberTypeResponse and NonCompartmentMemberInstanceResponse

* spotless

* Remove version restriction

* Differentiate exceptions thrown by different code

---------

Co-authored-by: juan.marchionatto <juan.marchionatto@smilecdr.com>
This commit is contained in:
jmarchionatto 2023-12-21 12:57:58 -05:00 committed by GitHub
parent 0785411be4
commit 836b755ad3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 582 additions and 155 deletions

View File

@ -0,0 +1,6 @@
---
type: add
issue: 5558
title: "An interceptor was added to block resource updates which would cause the resource to change Patient compartment.
Please see [JPA Server: Block Resource Updates Changing Patient Compartment](/hapi-fhir/docs/interceptors/built_in_server_interceptors.html#jpa-server-block-resource-updates-changing-patient-compartment)
for more information."

View File

@ -305,7 +305,14 @@ The CascadingDeleteInterceptor allows clients to request deletes be cascaded to
The OverridePathBasedReferentialIntegrityForDeletesInterceptor can be registered and configured to allow resources to be deleted even if other resources have outgoing references to the deleted resource. While it is generally a bad idea to allow deletion of resources that are referred to from other resources, there are circumstances where it is desirable. For example, if you have Provenance or AuditEvent resources that refer to a Patient resource that was created in error, you might want to alow the Patient to be deleted while leaving the Provenance and AuditEvent resources intact (including the now-invalid outgoing references to that Patient). The OverridePathBasedReferentialIntegrityForDeletesInterceptor can be registered and configured to allow resources to be deleted even if other resources have outgoing references to the deleted resource. While it is generally a bad idea to allow deletion of resources that are referred to from other resources, there are circumstances where it is desirable. For example, if you have Provenance or AuditEvent resources that refer to a Patient resource that was created in error, you might want to alow the Patient to be deleted while leaving the Provenance and AuditEvent resources intact (including the now-invalid outgoing references to that Patient).
This interceptor uses FHIRPath expressions to indicate the resource paths that should not have referential integrity applied to them. For example, if this interceptor is configured with a path of `AuditEvent.agent.who`, a Patient resource would be allowed to be deleted even if one or more AuditEvents had references in that path to the given Patient (unless other resources also had references to the Patient). This interceptor uses FHIRPath expressions to indicate the resource paths that should not have referential integrity applied to them. For example, if this interceptor is configured with a path of `AuditEvent.agent.who`, a Patient resource would be allowed to be deleted even if one or more AuditEvents had references in that path to the given Patient (unless other resources also had references to the Patient).
# JPA Server: Block Resource Updates Changing Patient Compartment
* [PatientCompartmentEnforcingInterceptor Source](https://github.com/hapifhir/hapi-fhir/blob/master/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/PatientCompartmentEnforcingInterceptor.java)
The PatientCompartmentEnforcingInterceptor can be registered to block resource updates which would change the resource's patient compartment. This interceptor requires FHIR version R4 or later.
If the JPA server has [partitioning](/docs/server_jpa_partitioning/partitioning.html) enabled, and [Tenant Identification Strategy](/docs/server_plain/multitenancy.html) is PATIENT_ID, the PatientCompartmentEnforcingInterceptor can be used to block resources to change partition as a consequence of updating a patient reference.
# JPA Server: Retry on Version Conflicts # JPA Server: Retry on Version Conflicts

View File

@ -1,4 +1,4 @@
# Changelog: 2023 # Changelog: 2023
<th:block th:insert="fragment_changelog.md :: changelog('2023', '')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2023', '')}"/>

View File

@ -1,4 +1,4 @@
# Changelog: 2014 # Changelog: 2014
<th:block th:insert="fragment_changelog.md :: changelog('2014', '2014')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2014', '2014')}"/>

View File

@ -1,4 +1,4 @@
# Changelog: 2015 # Changelog: 2015
<th:block th:insert="fragment_changelog.md :: changelog('2015', '2015')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2015', '2015')}"/>

View File

@ -1,4 +1,4 @@
# Changelog: 2016 # Changelog: 2016
<th:block th:insert="fragment_changelog.md :: changelog('2016', '2016')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2016', '2016')}"/>

View File

@ -1,4 +1,4 @@
# Changelog: 2017 # Changelog: 2017
<th:block th:insert="fragment_changelog.md :: changelog('2017', '2017')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2017', '2017')}"/>

View File

@ -1,4 +1,4 @@
# Changelog: 2018 # Changelog: 2018
<th:block th:insert="fragment_changelog.md :: changelog('2018', '2018')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2018', '2018')}"/>

View File

@ -1,4 +1,4 @@
# Changelog: 2019 # Changelog: 2019
<th:block th:insert="fragment_changelog.md :: changelog('2019', '2019')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2019', '2019')}"/>

View File

@ -1,5 +1,5 @@
# Changelog: 2020 # Changelog: 2020
<th:block th:insert="fragment_changelog.md :: changelog('2020', '2020')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2020', '2020')}"/>

View File

@ -1,5 +1,5 @@
# Changelog: 2021 # Changelog: 2021
<th:block th:insert="fragment_changelog.md :: changelog('2021', '2021')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2021', '2021')}"/>

View File

@ -1,5 +1,5 @@
# Changelog: 2022 # Changelog: 2022
<th:block th:insert="fragment_changelog.md :: changelog('2022', '2022')"/> <th:block th:insert="~{fragment_changelog.md :: changelog('2022', '2022')}"/>

View File

@ -176,4 +176,22 @@ public class PartitionSettings {
*/ */
ALLOWED_UNQUALIFIED, ALLOWED_UNQUALIFIED,
} }
public enum BlockPatientCompartmentUpdateMode {
/**
* Resource updates which would change resource's patient compartment are blocked.
*/
ALWAYS,
/**
* Resource updates which would change resource's patient compartment are blocked
* when Partition Selection Mode is PATIENT_ID
*/
DEFAULT,
/**
* Resource updates which would change resource's patient compartment are allowed.
*/
NEVER,
}
} }

View File

@ -0,0 +1,103 @@
package ca.uhn.fhir.jpa.interceptor;
import ca.uhn.fhir.jpa.model.config.PartitionSettings;
import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
import ca.uhn.fhir.jpa.test.BaseJpaR4Test;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import org.hl7.fhir.r4.model.Annotation;
import org.hl7.fhir.r4.model.Observation;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class PatientCompartmentEnforcingInterceptorTest extends BaseJpaR4Test {
public static final int ALTERNATE_DEFAULT_ID = -1;
private PatientIdPartitionInterceptor mySvc;
private ForceOffsetSearchModeInterceptor myForceOffsetSearchModeInterceptor;
private PatientCompartmentEnforcingInterceptor myUpdateCrossPartitionInterceptor;
@Autowired
private ISearchParamExtractor mySearchParamExtractor;
@Override
@BeforeEach
public void before() throws Exception {
super.before();
mySvc = new PatientIdPartitionInterceptor(myFhirContext, mySearchParamExtractor, myPartitionSettings);
myForceOffsetSearchModeInterceptor = new ForceOffsetSearchModeInterceptor();
myUpdateCrossPartitionInterceptor = new PatientCompartmentEnforcingInterceptor(
myFhirContext, mySearchParamExtractor);
myInterceptorRegistry.registerInterceptor(mySvc);
myInterceptorRegistry.registerInterceptor(myForceOffsetSearchModeInterceptor);
myInterceptorRegistry.registerInterceptor(myUpdateCrossPartitionInterceptor);
myPartitionSettings.setPartitioningEnabled(true);
myPartitionSettings.setUnnamedPartitionMode(true);
myPartitionSettings.setDefaultPartitionId(ALTERNATE_DEFAULT_ID);
}
@AfterEach
public void after() {
myInterceptorRegistry.unregisterInterceptor(mySvc);
myInterceptorRegistry.unregisterInterceptor(myForceOffsetSearchModeInterceptor);
myInterceptorRegistry.unregisterInterceptor(myUpdateCrossPartitionInterceptor);
myPartitionSettings.setPartitioningEnabled(false);
myPartitionSettings.setUnnamedPartitionMode(new PartitionSettings().isUnnamedPartitionMode());
myPartitionSettings.setDefaultPartitionId(new PartitionSettings().getDefaultPartitionId());
}
@Test
public void testUpdateResource_whenCrossingPatientCompartment_throws() {
createPatientA();
createPatientB();
Observation obs = new Observation();
obs.getSubject().setReference("Patient/A");
myObservationDao.create(obs, new SystemRequestDetails());
// try updating observation's patient, which would cross partition boundaries
obs.getSubject().setReference("Patient/B");
InternalErrorException thrown = assertThrows(InternalErrorException.class, () -> myObservationDao.update(obs, new SystemRequestDetails()));
assertEquals("HAPI-2476: Resource compartment changed. Was a referenced Patient changed?", thrown.getMessage());
}
@Test
public void testUpdateResource_whenNotCrossingPatientCompartment_allows() {
createPatientA();
Observation obs = new Observation();
obs.getSubject().setReference("Patient/A");
myObservationDao.create(obs, new SystemRequestDetails());
obs.getNote().add(new Annotation().setText("some text"));
obs.setStatus(Observation.ObservationStatus.CORRECTED);
myObservationDao.update(obs, new SystemRequestDetails());
}
private void createPatientA() {
Patient patient = new Patient();
patient.setId("Patient/A");
patient.setActive(true);
myPatientDao.update(patient, new SystemRequestDetails());
}
private void createPatientB() {
Patient patient = new Patient();
patient.setId("Patient/B");
patient.setActive(true);
myPatientDao.update(patient, new SystemRequestDetails());
}
}

View File

@ -5,8 +5,6 @@ import ca.uhn.fhir.batch2.jobs.export.BulkDataExportProvider;
import ca.uhn.fhir.batch2.model.JobInstance; import ca.uhn.fhir.batch2.model.JobInstance;
import ca.uhn.fhir.batch2.model.StatusEnum; import ca.uhn.fhir.batch2.model.StatusEnum;
import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.jpa.api.model.BulkExportJobResults; import ca.uhn.fhir.jpa.api.model.BulkExportJobResults;
import ca.uhn.fhir.jpa.batch.models.Batch2JobStartResponse; import ca.uhn.fhir.jpa.batch.models.Batch2JobStartResponse;
import ca.uhn.fhir.jpa.bulk.export.model.BulkExportResponseJson; import ca.uhn.fhir.jpa.bulk.export.model.BulkExportResponseJson;
@ -14,7 +12,6 @@ import ca.uhn.fhir.jpa.entity.PartitionEntity;
import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings;
import ca.uhn.fhir.jpa.model.entity.ResourceTable; import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.model.util.JpaConstants; import ca.uhn.fhir.jpa.model.util.JpaConstants;
import ca.uhn.fhir.jpa.partition.RequestPartitionHelperSvc;
import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
@ -29,12 +26,12 @@ import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.test.utilities.ITestDataBuilder;
import ca.uhn.fhir.util.JsonUtil; import ca.uhn.fhir.util.JsonUtil;
import com.google.common.collect.Sets; import com.google.common.collect.Sets;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpGet;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.CapabilityStatement; import org.hl7.fhir.r4.model.CapabilityStatement;
import org.hl7.fhir.r4.model.Condition; import org.hl7.fhir.r4.model.Condition;
@ -42,7 +39,6 @@ import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Organization; import org.hl7.fhir.r4.model.Organization;
import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.Resource; import org.hl7.fhir.r4.model.Resource;
import org.hl7.fhir.r4.model.StringType;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@ -51,13 +47,10 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Date; import java.util.Date;
@ -109,9 +102,10 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
createPatient(withTenant(TENANT_B), withActiveFalse()); createPatient(withTenant(TENANT_B), withActiveFalse());
runInTransaction(() -> { runInTransaction(() -> {
PartitionEntity partition = myPartitionDao.findForName(TENANT_A).orElseThrow(() -> new IllegalStateException()); PartitionEntity partition = myPartitionDao.findForName(TENANT_A).orElseThrow(IllegalStateException::new);
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertEquals(partition.getId(), resourceTable.getPartitionId().getPartitionId()); assert resourceTable.getPartitionId() != null;
assertEquals(partition.getId(), resourceTable.getPartitionId().getPartitionId());
}); });
// Now read back // Now read back
@ -147,7 +141,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
createPatient(withTenant(TENANT_B), withActiveFalse()); createPatient(withTenant(TENANT_B), withActiveFalse());
runInTransaction(() -> { runInTransaction(() -> {
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertNull(resourceTable.getPartitionId()); assertNull(resourceTable.getPartitionId());
}); });
@ -187,7 +181,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeIncludeDeleted( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeIncludeDeleted(
"Patient", Arrays.asList(patientId) "Patient", List.of(patientId)
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -195,7 +189,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoType( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoType(
"Patient", Arrays.asList(patientId), true "Patient", List.of(patientId), true
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -206,7 +200,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeIncludeDeleted( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeIncludeDeleted(
"Patient", Arrays.asList(patientId) "Patient", List.of(patientId)
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -214,7 +208,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoType( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoType(
"Patient", Arrays.asList(patientId), true "Patient", List.of(patientId), true
); );
assertThat(forcedIds, hasSize(0)); assertThat(forcedIds, hasSize(0));
}); });
@ -229,7 +223,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull(
"Patient", Arrays.asList(patientId), false "Patient", List.of(patientId), false
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -237,7 +231,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull(
"Patient", Arrays.asList(patientId), true "Patient", List.of(patientId), true
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -248,7 +242,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull(
"Patient", Arrays.asList(patientId), false "Patient", List.of(patientId), false
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -256,7 +250,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionNull(
"Patient", Arrays.asList(patientId), true "Patient", List.of(patientId), true
); );
assertEquals(0, forcedIds.size()); assertEquals(0, forcedIds.size());
}); });
@ -273,7 +267,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID), false "Patient", List.of(patientId), List.of(TENANT_A_ID), false
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -281,7 +275,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID), true "Patient", List.of(patientId), List.of(TENANT_A_ID), true
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -291,7 +285,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID), false "Patient", List.of(patientId), List.of(TENANT_A_ID), false
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -299,14 +293,14 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartitionIdOrNullPartitionId(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID), true "Patient", List.of(patientId), List.of(TENANT_A_ID), true
); );
assertEquals(0, forcedIds.size()); assertEquals(0, forcedIds.size());
}); });
} }
@Test @Test
public void testFindAndResolveByForcedIdWithNoTypeInPartition() throws IOException { public void testFindAndResolveByForcedIdWithNoTypeInPartition() {
// Create patients // Create patients
String patientId = "AAA"; String patientId = "AAA";
IIdType idA = createPatient(withTenant(TENANT_A), withId(patientId)); IIdType idA = createPatient(withTenant(TENANT_A), withId(patientId));
@ -316,7 +310,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), false "Patient", List.of(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), false
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -324,7 +318,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), true "Patient", List.of(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), true
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -334,7 +328,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and include deleted // Search and include deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), false "Patient", List.of(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), false
); );
assertContainsSingleForcedId(forcedIds, patientId); assertContainsSingleForcedId(forcedIds, patientId);
}); });
@ -342,7 +336,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
// Search and filter deleted // Search and filter deleted
runInTransaction(() -> { runInTransaction(() -> {
Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition( Collection<Object[]> forcedIds = myResourceTableDao.findAndResolveByForcedIdWithNoTypeInPartition(
"Patient", Arrays.asList(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), true "Patient", List.of(patientId), Arrays.asList(TENANT_A_ID, TENANT_B_ID), true
); );
assertEquals(0, forcedIds.size()); assertEquals(0, forcedIds.size());
}); });
@ -367,7 +361,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
IIdType idA = createResource("NamingSystem", withTenant(JpaConstants.DEFAULT_PARTITION_NAME), withStatus("draft")); IIdType idA = createResource("NamingSystem", withTenant(JpaConstants.DEFAULT_PARTITION_NAME), withStatus("draft"));
runInTransaction(() -> { runInTransaction(() -> {
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertNull(resourceTable.getPartitionId()); assertNull(resourceTable.getPartitionId());
}); });
@ -421,10 +415,12 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
IdType idB = new IdType(response.getEntry().get(1).getResponse().getLocation()); IdType idB = new IdType(response.getEntry().get(1).getResponse().getLocation());
runInTransaction(() -> { runInTransaction(() -> {
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertEquals(1, resourceTable.getPartitionId().getPartitionId()); assert resourceTable.getPartitionId() != null;
resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); assertEquals(1, resourceTable.getPartitionId().getPartitionId());
assertEquals(1, resourceTable.getPartitionId().getPartitionId()); resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assert resourceTable.getPartitionId() != null;
assertEquals(1, resourceTable.getPartitionId().getPartitionId());
}); });
} }
@ -493,10 +489,11 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
IIdType idB = myPatientDao.create((Patient) patientB, requestDetails).getId(); IIdType idB = myPatientDao.create((Patient) patientB, requestDetails).getId();
runInTransaction(() -> { runInTransaction(() -> {
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertNull(resourceTable.getPartitionId()); assertNull(resourceTable.getPartitionId());
resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertEquals(2, resourceTable.getPartitionId().getPartitionId()); assert resourceTable.getPartitionId() != null;
assertEquals(2, resourceTable.getPartitionId().getPartitionId());
}); });
@ -556,10 +553,11 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
myPatientDao.update((Patient) patientB, requestDetails); myPatientDao.update((Patient) patientB, requestDetails);
runInTransaction(() -> { runInTransaction(() -> {
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertNull(resourceTable.getPartitionId()); assertNull(resourceTable.getPartitionId());
resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertEquals(2, resourceTable.getPartitionId().getPartitionId()); assert resourceTable.getPartitionId() != null;
assertEquals(2, resourceTable.getPartitionId().getPartitionId());
}); });
@ -619,10 +617,12 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
IdType idB = new IdType(response.getEntry().get(1).getResponse().getLocation()); IdType idB = new IdType(response.getEntry().get(1).getResponse().getLocation());
runInTransaction(() -> { runInTransaction(() -> {
ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); ResourceTable resourceTable = myResourceTableDao.findById(idA.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assertEquals(1, resourceTable.getPartitionId().getPartitionId()); assert resourceTable.getPartitionId() != null;
resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(() -> new IllegalStateException()); assertEquals(1, resourceTable.getPartitionId().getPartitionId());
assertEquals(1, resourceTable.getPartitionId().getPartitionId()); resourceTable = myResourceTableDao.findById(idB.getIdPartAsLong()).orElseThrow(IllegalStateException::new);
assert resourceTable.getPartitionId() != null;
assertEquals(1, resourceTable.getPartitionId().getPartitionId());
}); });
} }
@ -646,7 +646,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
} }
@Test @Test
public void testIncludeInTenantWithAssignedID() throws Exception { public void testIncludeInTenantWithAssignedID() {
IIdType idA = createResource("Patient", withTenant(JpaConstants.DEFAULT_PARTITION_NAME), withId("test"), withFamily("Smith"), withActiveTrue()); IIdType idA = createResource("Patient", withTenant(JpaConstants.DEFAULT_PARTITION_NAME), withId("test"), withFamily("Smith"), withActiveTrue());
createConditionWithAllowedUnqualified(idA); createConditionWithAllowedUnqualified(idA);
Bundle response = myClient.search().byUrl(myClient.getServerBase() + "/" + TENANT_A + "/Condition?subject=Patient/" + idA.getIdPart() + "&_include=Condition:subject").returnBundle(Bundle.class).execute(); Bundle response = myClient.search().byUrl(myClient.getServerBase() + "/" + TENANT_A + "/Condition?subject=Patient/" + idA.getIdPart() + "&_include=Condition:subject").returnBundle(Bundle.class).execute();
@ -654,7 +654,7 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
} }
@Test @Test
public void testIncludeInTenantWithAutoGeneratedID() throws Exception { public void testIncludeInTenantWithAutoGeneratedID() {
IIdType idA = createResource("Patient", withTenant(JpaConstants.DEFAULT_PARTITION_NAME), withFamily("Smith"), withActiveTrue()); IIdType idA = createResource("Patient", withTenant(JpaConstants.DEFAULT_PARTITION_NAME), withFamily("Smith"), withActiveTrue());
createConditionWithAllowedUnqualified(idA); createConditionWithAllowedUnqualified(idA);
Bundle response = myClient.search().byUrl(myClient.getServerBase() + "/" + TENANT_A + "/Condition?subject=Patient/" + idA.getIdPart() + "&_include=Condition:subject").returnBundle(Bundle.class).execute(); Bundle response = myClient.search().byUrl(myClient.getServerBase() + "/" + TENANT_A + "/Condition?subject=Patient/" + idA.getIdPart() + "&_include=Condition:subject").returnBundle(Bundle.class).execute();
@ -678,25 +678,8 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
@Mock @Mock
private IJobCoordinator myJobCoordinator; private IJobCoordinator myJobCoordinator;
@Spy
private RequestPartitionHelperSvc myRequestPartitionHelperSvc = new MultitenantServerR4Test.PartitionTesting.MyRequestPartitionHelperSvc();
String myTenantName = null; String myTenantName = null;
private class MyRequestPartitionHelperSvc extends RequestPartitionHelperSvc {
@Override
public RequestPartitionId determineReadPartitionForRequest(RequestDetails theRequest, ReadPartitionIdRequestDetails theDetails) {
return RequestPartitionId.fromPartitionName(myTenantName);
}
@Override
public void validateHasPartitionPermissions(RequestDetails theRequest, String theResourceType, RequestPartitionId theRequestPartitionId) {
return;
}
}
@Test @Test
public void testBulkExportForDifferentPartitions() throws IOException { public void testBulkExportForDifferentPartitions() throws IOException {
setBulkDataExportProvider(); setBulkDataExportProvider();
@ -745,13 +728,6 @@ public class MultitenantServerR4Test extends BaseMultitenantResourceProviderR4Te
when(servletRequestDetails.getServletResponse()) when(servletRequestDetails.getServletResponse())
.thenReturn(mockResponse); .thenReturn(mockResponse);
List<IPrimitiveType<String>> filters = new ArrayList<>();
if (options.getFilters() != null) {
for (String v : options.getFilters()) {
filters.add(new StringType(v));
}
}
//perform export-poll-status //perform export-poll-status
myTenantName = createInPartition; myTenantName = createInPartition;
HttpGet get = new HttpGet(buildExportUrl(createInPartition, jobId)); HttpGet get = new HttpGet(buildExportUrl(createInPartition, jobId));

View File

@ -166,6 +166,12 @@
<version>${project.version}</version> <version>${project.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>${mockito_version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -0,0 +1,65 @@
package ca.uhn.fhir.jpa.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.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.util.StopWatch;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.apache.commons.lang3.StringUtils.EMPTY;
/**
* This interceptor can be used to block resource updates which would make resource patient compartment change.
* <p/>
* This could be used when the JPA server has partitioning enabled, and Tenant Identification Strategy is PATIENT_ID.
*/
@Interceptor
public class PatientCompartmentEnforcingInterceptor {
private static final Logger ourLog = LoggerFactory.getLogger(PatientCompartmentEnforcingInterceptor.class);
private final FhirContext myFhirContext;
private final ISearchParamExtractor mySearchParamExtractor;
public PatientCompartmentEnforcingInterceptor(
FhirContext theFhirContext, ISearchParamExtractor theSearchParamExtractor) {
myFhirContext = theFhirContext;
mySearchParamExtractor = theSearchParamExtractor;
}
/**
* Blocks resource updates which would make the resource change Patient Compartment.
* @param theOldResource the original resource state
* @param theResource the updated resource state
*/
@Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
public void storagePreStorageResourceUpdated(IBaseResource theOldResource, IBaseResource theResource) {
ourLog.info("Interceptor STORAGE_PRESTORAGE_RESOURCE_UPDATED - started");
StopWatch stopWatch = new StopWatch();
try {
String patientCompartmentOld = ResourceCompartmentUtil.getPatientCompartmentIdentity(
theOldResource, myFhirContext, mySearchParamExtractor)
.orElse(EMPTY);
String patientCompartmentCurrent = ResourceCompartmentUtil.getPatientCompartmentIdentity(
theResource, myFhirContext, mySearchParamExtractor)
.orElse(EMPTY);
if (!StringUtils.equals(patientCompartmentOld, patientCompartmentCurrent)) {
// Avoid disclosing compartments in message, which could have security implications
throw new InternalErrorException(
Msg.code(2476) + "Resource compartment changed. Was a referenced Patient changed?");
}
} finally {
ourLog.info("Interceptor STORAGE_PRESTORAGE_RESOURCE_UPDATED - ended, execution took {}", stopWatch);
}
}
}

View File

@ -30,23 +30,21 @@ import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.jpa.searchparam.extractor.BaseSearchParamExtractor;
import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
import ca.uhn.fhir.jpa.util.ResourceCompartmentUtil;
import ca.uhn.fhir.model.api.IQueryParameterType; import ca.uhn.fhir.model.api.IQueryParameterType;
import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.param.ReferenceParam; import ca.uhn.fhir.rest.param.ReferenceParam;
import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
import jakarta.annotation.Nonnull; import jakarta.annotation.Nonnull;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IBaseReference;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.IdType; import org.hl7.fhir.r4.model.IdType;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isBlank;
@ -92,40 +90,28 @@ public class PatientIdPartitionInterceptor {
@Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE) @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)
public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) { public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) {
RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource); RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource);
List<RuntimeSearchParam> compartmentSps = getCompartmentSearchParams(resourceDef); List<RuntimeSearchParam> compartmentSps =
ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
if (compartmentSps.isEmpty()) { if (compartmentSps.isEmpty()) {
return provideNonCompartmentMemberTypeResponse(theResource); return provideNonCompartmentMemberTypeResponse(theResource);
} }
String compartmentIdentity; Optional<String> oCompartmentIdentity;
if (resourceDef.getName().equals("Patient")) { if (resourceDef.getName().equals("Patient")) {
compartmentIdentity = theResource.getIdElement().getIdPart(); oCompartmentIdentity =
if (isBlank(compartmentIdentity)) { Optional.ofNullable(theResource.getIdElement().getIdPart());
if (oCompartmentIdentity.isEmpty()) {
throw new MethodNotAllowedException( throw new MethodNotAllowedException(
Msg.code(1321) + "Patient resource IDs must be client-assigned in patient compartment mode"); Msg.code(1321) + "Patient resource IDs must be client-assigned in patient compartment mode");
} }
} else { } else {
compartmentIdentity = compartmentSps.stream() oCompartmentIdentity =
.flatMap(param -> Arrays.stream(BaseSearchParamExtractor.splitPathsR4(param.getPath()))) ResourceCompartmentUtil.getResourceCompartment(theResource, compartmentSps, mySearchParamExtractor);
.filter(StringUtils::isNotBlank)
.map(path -> mySearchParamExtractor
.getPathValueExtractor(theResource, path)
.get())
.filter(t -> !t.isEmpty())
.map(t -> t.get(0))
.filter(t -> t instanceof IBaseReference)
.map(t -> (IBaseReference) t)
.map(t -> t.getReferenceElement().getValue())
.map(t -> new IdType(t).getIdPart())
.filter(StringUtils::isNotBlank)
.findFirst()
.orElse(null);
if (isBlank(compartmentIdentity)) {
return provideNonCompartmentMemberInstanceResponse(theResource);
}
} }
return provideCompartmentMemberInstanceResponse(theRequestDetails, compartmentIdentity); return oCompartmentIdentity
.map(ci -> provideCompartmentMemberInstanceResponse(theRequestDetails, ci))
.orElseGet(() -> provideNonCompartmentMemberInstanceResponse(theResource));
} }
@Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ) @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)
@ -135,7 +121,8 @@ public class PatientIdPartitionInterceptor {
return provideNonCompartmentMemberTypeResponse(null); return provideNonCompartmentMemberTypeResponse(null);
} }
RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theReadDetails.getResourceType()); RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theReadDetails.getResourceType());
List<RuntimeSearchParam> compartmentSps = getCompartmentSearchParams(resourceDef); List<RuntimeSearchParam> compartmentSps =
ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
if (compartmentSps.isEmpty()) { if (compartmentSps.isEmpty()) {
return provideNonCompartmentMemberTypeResponse(null); return provideNonCompartmentMemberTypeResponse(null);
} }

View File

@ -219,54 +219,53 @@ public abstract class BaseRequestPartitionHelperSvc implements IRequestPartition
@Nullable RequestDetails theRequest, @Nonnull IBaseResource theResource, @Nonnull String theResourceType) { @Nullable RequestDetails theRequest, @Nonnull IBaseResource theResource, @Nonnull String theResourceType) {
RequestPartitionId requestPartitionId; RequestPartitionId requestPartitionId;
if (myPartitionSettings.isPartitioningEnabled()) { if (!myPartitionSettings.isPartitioningEnabled()) {
boolean nonPartitionableResource = myNonPartitionableResourceNames.contains(theResourceType); return RequestPartitionId.allPartitions();
// TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through
// SystemRequestDetails instead.
if ((theRequest == null || theRequest instanceof SystemRequestDetails) && nonPartitionableResource) {
return RequestPartitionId.defaultPartition();
}
if (theRequest instanceof SystemRequestDetails
&& systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) {
requestPartitionId =
getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource);
} else {
if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, myInterceptorBroadcaster, theRequest)) {
// Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY
HookParams params = new HookParams()
.add(RequestDetails.class, theRequest)
.addIfMatchesType(ServletRequestDetails.class, theRequest);
requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject(
myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params);
} else {
// This is an external Request (e.g. ServletRequestDetails) so we want to figure out the partition
// via interceptor.
// Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE
HookParams params = new HookParams()
.add(IBaseResource.class, theResource)
.add(RequestDetails.class, theRequest)
.addIfMatchesType(ServletRequestDetails.class, theRequest);
requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject(
myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params);
}
// If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send
// to DEFAULT
if (nonPartitionableResource && requestPartitionId == null) {
requestPartitionId = RequestPartitionId.defaultPartition();
}
}
String resourceName = myFhirContext.getResourceType(theResource);
validateSinglePartitionForCreate(
requestPartitionId, resourceName, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE);
return validateNormalizeAndNotifyHooksForRead(requestPartitionId, theRequest, theResourceType);
} }
return RequestPartitionId.allPartitions(); boolean nonPartitionableResource = myNonPartitionableResourceNames.contains(theResourceType);
// TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through
// SystemRequestDetails instead.
if ((theRequest == null || theRequest instanceof SystemRequestDetails) && nonPartitionableResource) {
return RequestPartitionId.defaultPartition();
}
if (theRequest instanceof SystemRequestDetails
&& systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) {
requestPartitionId =
getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource);
} else {
if (hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, myInterceptorBroadcaster, theRequest)) {
// Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY
HookParams params = new HookParams()
.add(RequestDetails.class, theRequest)
.addIfMatchesType(ServletRequestDetails.class, theRequest);
requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject(
myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params);
} else {
// This is an external Request (e.g. ServletRequestDetails) so we want to figure out the partition
// via interceptor.
// Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE
HookParams params = new HookParams()
.add(IBaseResource.class, theResource)
.add(RequestDetails.class, theRequest)
.addIfMatchesType(ServletRequestDetails.class, theRequest);
requestPartitionId = (RequestPartitionId) doCallHooksAndReturnObject(
myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params);
}
// If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send
// to DEFAULT
if (nonPartitionableResource && requestPartitionId == null) {
requestPartitionId = RequestPartitionId.defaultPartition();
}
}
String resourceName = myFhirContext.getResourceType(theResource);
validateSinglePartitionForCreate(requestPartitionId, resourceName, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE);
return validateNormalizeAndNotifyHooksForRead(requestPartitionId, theRequest, theResourceType);
} }
private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) { private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) {

View File

@ -0,0 +1,98 @@
package ca.uhn.fhir.jpa.util;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.searchparam.extractor.BaseSearchParamExtractor;
import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
import jakarta.annotation.Nonnull;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IBaseReference;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.IdType;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringUtils.isBlank;
public class ResourceCompartmentUtil {
/**
* Extract, if exists, the patient compartment identity of the received resource.
* It must be invoked in patient compartment mode.
* @param theResource the resource to which extract the patient compartment identity
* @param theFhirContext the active FhirContext
* @param theSearchParamExtractor the configured search parameter extractor
* @return the optional patient compartment identifier
* @throws MethodNotAllowedException if received resource is of type "Patient" and ID is not assigned.
*/
public static Optional<String> getPatientCompartmentIdentity(
IBaseResource theResource, FhirContext theFhirContext, ISearchParamExtractor theSearchParamExtractor) {
RuntimeResourceDefinition resourceDef = theFhirContext.getResourceDefinition(theResource);
List<RuntimeSearchParam> patientCompartmentSps =
ResourceCompartmentUtil.getPatientCompartmentSearchParams(resourceDef);
if (patientCompartmentSps.isEmpty()) {
return Optional.empty();
}
if (resourceDef.getName().equals("Patient")) {
String compartmentIdentity = theResource.getIdElement().getIdPart();
if (isBlank(compartmentIdentity)) {
throw new MethodNotAllowedException(
Msg.code(2475) + "Patient resource IDs must be client-assigned in patient compartment mode");
}
return Optional.of(compartmentIdentity);
}
return getResourceCompartment(theResource, patientCompartmentSps, theSearchParamExtractor);
}
/**
* Extracts and returns an optional compartment of the received resource
* @param theResource source resource which compartment is extracted
* @param theCompartmentSps the RuntimeSearchParam list involving the searched compartment
* @param mySearchParamExtractor the ISearchParamExtractor to be used to extract the parameter values
* @return optional compartment of the received resource
*/
public static Optional<String> getResourceCompartment(
IBaseResource theResource,
List<RuntimeSearchParam> theCompartmentSps,
ISearchParamExtractor mySearchParamExtractor) {
return theCompartmentSps.stream()
.flatMap(param -> Arrays.stream(BaseSearchParamExtractor.splitPathsR4(param.getPath())))
.filter(StringUtils::isNotBlank)
.map(path -> mySearchParamExtractor
.getPathValueExtractor(theResource, path)
.get())
.filter(t -> !t.isEmpty())
.map(t -> t.get(0))
.filter(t -> t instanceof IBaseReference)
.map(t -> (IBaseReference) t)
.map(t -> t.getReferenceElement().getValue())
.map(t -> new IdType(t).getIdPart())
.filter(StringUtils::isNotBlank)
.findFirst();
}
/**
* Returns a {@code RuntimeSearchParam} list with the parameters extracted from the received
* {@code RuntimeResourceDefinition}, which are of type REFERENCE and have a membership compartment
* for "Patient" resource
* @param resourceDef the RuntimeResourceDefinition providing the RuntimeSearchParam list
* @return the RuntimeSearchParam filtered list
*/
@Nonnull
public static List<RuntimeSearchParam> getPatientCompartmentSearchParams(RuntimeResourceDefinition resourceDef) {
return resourceDef.getSearchParams().stream()
.filter(param -> param.getParamType() == RestSearchParameterTypeEnum.REFERENCE)
.filter(param -> param.getProvidesMembershipInCompartments() != null
&& param.getProvidesMembershipInCompartments().contains("Patient"))
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,162 @@
package ca.uhn.fhir.jpa.util;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Reference;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ResourceCompartmentUtilTest {
@Mock
private RuntimeResourceDefinition myRuntimeResourceDefinition;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private IBaseResource myResource;
@Mock
private FhirContext myFhirContext;
private List<RuntimeSearchParam> myCompartmentSearchParams;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private ISearchParamExtractor mySearchParamExtractor;
@Test
void getResourceCompartment() {
myCompartmentSearchParams = getMockSearchParams(true);
when(mySearchParamExtractor.getPathValueExtractor(myResource, "Observation.subject"))
.thenReturn(() -> List.of(new Reference("Patient/P01")));
Optional<String> oCompartment = ResourceCompartmentUtil.getResourceCompartment(
myResource, myCompartmentSearchParams, mySearchParamExtractor);
assertTrue(oCompartment.isPresent());
assertEquals("P01", oCompartment.get());
}
@Test
void getPatientCompartmentSearchParams() {
myCompartmentSearchParams = getMockSearchParams(true);
when(myRuntimeResourceDefinition.getSearchParams()).thenReturn(myCompartmentSearchParams);
List<RuntimeSearchParam> result = ResourceCompartmentUtil.getPatientCompartmentSearchParams(myRuntimeResourceDefinition);
assertEquals(2, result.size());
}
@Nested
public class TestGetPatientCompartmentIdentity {
@Test
void whenNoPatientCompartmentsReturnsEmpty() {
myCompartmentSearchParams = getMockSearchParams(false);
when(myFhirContext.getResourceDefinition(myResource)).thenReturn(myRuntimeResourceDefinition);
when(myRuntimeResourceDefinition.getSearchParams()).thenReturn(myCompartmentSearchParams);
Optional<String> result = ResourceCompartmentUtil.getPatientCompartmentIdentity(myResource, myFhirContext, mySearchParamExtractor);
assertTrue(result.isEmpty());
}
@Test
void whenPatientResource_andNoId_throws() {
myCompartmentSearchParams = getMockSearchParams(true);
when(myFhirContext.getResourceDefinition(myResource)).thenReturn(myRuntimeResourceDefinition);
when(myRuntimeResourceDefinition.getSearchParams()).thenReturn(myCompartmentSearchParams);
when(myRuntimeResourceDefinition.getName()).thenReturn("Patient");
when(myResource.getIdElement().getIdPart()).thenReturn(null);
MethodNotAllowedException thrown = assertThrows(MethodNotAllowedException.class,
() -> ResourceCompartmentUtil.getPatientCompartmentIdentity(myResource, myFhirContext, mySearchParamExtractor));
assertEquals(Msg.code(2475) + "Patient resource IDs must be client-assigned in patient compartment mode", thrown.getMessage());
}
@Test
void whenPatientResource_whichHasId_returnsId() {
myCompartmentSearchParams = getMockSearchParams(true);
when(myFhirContext.getResourceDefinition(myResource)).thenReturn(myRuntimeResourceDefinition);
when(myRuntimeResourceDefinition.getSearchParams()).thenReturn(myCompartmentSearchParams);
when(myRuntimeResourceDefinition.getName()).thenReturn("Patient");
when(myResource.getIdElement().getIdPart()).thenReturn("Abc");
Optional<String> result = ResourceCompartmentUtil.getPatientCompartmentIdentity(myResource, myFhirContext, mySearchParamExtractor);
assertTrue(result.isPresent());
assertEquals("Abc", result.get());
}
@Test
void whenNoPatientResource_returnsPatientCompartment() {
// getResourceCompartment is tested independently, so here it is just (static) mocked
myCompartmentSearchParams = getMockSearchParams(true);
when(myFhirContext.getResourceDefinition(myResource)).thenReturn(myRuntimeResourceDefinition);
when(myRuntimeResourceDefinition.getSearchParams()).thenReturn(myCompartmentSearchParams);
when(myRuntimeResourceDefinition.getName()).thenReturn("Observation");
when(mySearchParamExtractor.getPathValueExtractor(myResource, "Observation.subject"))
.thenReturn(() -> List.of(new Reference("Patient/P01")));
// try (MockedStatic<ResourceCompartmentUtil> mockedUtil = Mockito.mockStatic(ResourceCompartmentUtil.class)) {
// mockedUtil.when(() -> ResourceCompartmentUtil.getResourceCompartment(
// myResource, myCompartmentSearchParams, mySearchParamExtractor)).thenReturn(Optional.of("P01"));
// execute
Optional<String> result = ResourceCompartmentUtil.getPatientCompartmentIdentity(myResource, myFhirContext, mySearchParamExtractor);
assertTrue(result.isPresent());
assertEquals("P01", result.get());
// }
}
}
private List<RuntimeSearchParam> getMockSearchParams(boolean providePatientCompartment) {
return List.of(
getMockSearchParam("subject", "Observation.subject", providePatientCompartment),
getMockSearchParam("performer", "Observation.performer", providePatientCompartment));
}
private RuntimeSearchParam getMockSearchParam(String theName, String thePath, boolean providePatientCompartment) {
RuntimeSearchParam rsp = mock(RuntimeSearchParam.class);
lenient().when(rsp.getParamType()).thenReturn(RestSearchParameterTypeEnum.REFERENCE);
lenient().when(rsp.getProvidesMembershipInCompartments()).thenReturn(getCompartmentsForParam(theName, providePatientCompartment));
lenient().when(rsp.getName()).thenReturn(theName);
lenient().when(rsp.getPath()).thenReturn(thePath);
return rsp;
}
private Set<String> getCompartmentsForParam(String theName, boolean theProvidePatientCompartment) {
if (theProvidePatientCompartment) {
return switch (theName) {
case "subject" -> Set.of("Device", "Patient");
case "performer" -> Set.of("Practitioner", "Patient", "RelatedPerson");
default -> Collections.emptySet();
};
} else {
return switch (theName) {
case "subject" -> Set.of("Device");
case "performer" -> Set.of("Practitioner", "RelatedPerson");
default -> Collections.emptySet();
};
}
}
}