diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_6_0/7342-enhance-rulebuilder-to-support-compartments-by-matchers.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_6_0/7342-enhance-rulebuilder-to-support-compartments-by-matchers.yaml new file mode 100644 index 000000000000..44e57a16b360 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_6_0/7342-enhance-rulebuilder-to-support-compartments-by-matchers.yaml @@ -0,0 +1,4 @@ +--- +type: add +issue: 7342 +title: "Enhanced the bulk export RuleBuilder code to support the identification of allowable Groups/Patients to export by a FHIR query matcher." diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java index 81778c0bc247..05798fbb925b 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java @@ -93,6 +93,7 @@ import ca.uhn.fhir.jpa.entity.TermValueSet; import ca.uhn.fhir.jpa.esr.ExternallyStoredResourceServiceRegistry; import ca.uhn.fhir.jpa.graphql.DaoRegistryGraphQLStorageServices; +import ca.uhn.fhir.jpa.interceptor.AuthResourceResolver; import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor; import ca.uhn.fhir.jpa.interceptor.JpaConsentContextServices; import ca.uhn.fhir.jpa.interceptor.OverridePathBasedReferentialIntegrityForDeletesInterceptor; @@ -205,6 +206,7 @@ import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; import ca.uhn.fhir.rest.server.interceptor.ResponseTerminologyTranslationInterceptor; import ca.uhn.fhir.rest.server.interceptor.ResponseTerminologyTranslationSvc; +import ca.uhn.fhir.rest.server.interceptor.auth.IAuthResourceResolver; import ca.uhn.fhir.rest.server.interceptor.consent.IConsentContextServices; import ca.uhn.fhir.rest.server.interceptor.partition.RequestTenantPartitionInterceptor; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; @@ -530,6 +532,11 @@ public IConsentContextServices consentContextServices() { return new JpaConsentContextServices(); } + @Bean + public IAuthResourceResolver authResourceResolver(DaoRegistry theDaoRegistry) { + return new AuthResourceResolver(theDaoRegistry); + } + @Bean @Lazy public DiffProvider diffProvider() { diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/AuthResourceResolver.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/AuthResourceResolver.java new file mode 100644 index 000000000000..c59a4415398d --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/AuthResourceResolver.java @@ -0,0 +1,57 @@ +/*- + * #%L + * HAPI FHIR JPA Server + * %% + * Copyright (C) 2014 - 2025 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.jpa.interceptor; + +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.param.TokenOrListParam; +import ca.uhn.fhir.rest.server.interceptor.auth.IAuthResourceResolver; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; + +import java.util.List; + +/** + * Small service class to inject DB access into an interceptor + * For example, used in bulk export security to allow querying for resource to match against permission argument filters + */ +public class AuthResourceResolver implements IAuthResourceResolver { + private final DaoRegistry myDaoRegistry; + + public AuthResourceResolver(DaoRegistry myDaoRegistry) { + this.myDaoRegistry = myDaoRegistry; + } + + public IBaseResource resolveResourceById(IIdType theResourceId) { + return myDaoRegistry + .getResourceDao(theResourceId.getResourceType()) + .read(theResourceId, new SystemRequestDetails()); + } + + public List resolveResourcesByIds(List theResourceIds, String theResourceType) { + TokenOrListParam t = new TokenOrListParam(null, theResourceIds.toArray(String[]::new)); + + SearchParameterMap m = new SearchParameterMap(); + m.add(Constants.PARAM_ID, t); + return myDaoRegistry.getResourceDao(theResourceType).searchForResources(m, new SystemRequestDetails()); + } +} diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/bulk/BulkDataExportAuthorizationQueryCountTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/bulk/BulkDataExportAuthorizationQueryCountTest.java new file mode 100644 index 000000000000..31000c5df8b4 --- /dev/null +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/bulk/BulkDataExportAuthorizationQueryCountTest.java @@ -0,0 +1,373 @@ +package ca.uhn.fhir.jpa.bulk; + +import ca.uhn.fhir.batch2.api.IFirstJobStepWorker; +import ca.uhn.fhir.batch2.api.IJobDataSink; +import ca.uhn.fhir.batch2.api.IJobStepWorker; +import ca.uhn.fhir.batch2.api.JobExecutionFailedException; +import ca.uhn.fhir.batch2.api.RunOutcome; +import ca.uhn.fhir.batch2.api.StepExecutionDetails; +import ca.uhn.fhir.batch2.api.VoidModel; +import ca.uhn.fhir.batch2.jobs.export.BulkDataExportProvider; +import ca.uhn.fhir.batch2.jobs.export.BulkExportAppCtx; +import ca.uhn.fhir.batch2.jobs.export.models.ResourceIdList; +import ca.uhn.fhir.batch2.model.JobDefinition; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; +import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test; +import ca.uhn.fhir.jpa.util.CircularQueueCaptureQueriesListener; +import ca.uhn.fhir.model.api.IModelJson; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor; +import ca.uhn.fhir.rest.server.interceptor.auth.IAuthResourceResolver; +import ca.uhn.fhir.rest.server.interceptor.auth.IAuthRule; +import ca.uhn.fhir.rest.server.interceptor.auth.IAuthRuleBuilderRuleBulkExport; +import ca.uhn.fhir.rest.server.interceptor.auth.PolicyEnum; +import ca.uhn.fhir.rest.server.interceptor.auth.RuleBuilder; +import ca.uhn.fhir.rest.server.provider.ProviderConstants; +import ca.uhn.fhir.test.utilities.server.RestfulServerExtension; + +import ca.uhn.fhir.util.Batch2JobDefinitionConstants; + +import jakarta.annotation.Nonnull; + +import java.util.List; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Group; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Reference; +import org.hl7.fhir.r4.model.StringType; + +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; + +@ContextConfiguration(classes = BulkDataExportAuthorizationQueryCountTest.MockBulkExportJobConfig.class) +public class BulkDataExportAuthorizationQueryCountTest extends BaseResourceProviderR4Test { + @RegisterExtension + public static final RestfulServerExtension ourPatientExportServer = new RestfulServerExtension(FhirContext.forR4Cached()) + .registerInterceptor(buildAuthInterceptorWithPatientExportByFilterPermissions(BulkExportJobParameters.ExportStyle.PATIENT)) + .keepAliveBetweenTests(); + + @RegisterExtension + public static final RestfulServerExtension ourGroupExportServer = new RestfulServerExtension(FhirContext.forR4Cached()) + .registerInterceptor(buildAuthInterceptorWithPatientExportByFilterPermissions(BulkExportJobParameters.ExportStyle.GROUP)) + .keepAliveBetweenTests(); + + private static final Identifier AUTH_IDENTIFIER = new Identifier().setSystem("foo").setValue("bar"); + private static final Identifier UNAUTH_IDENTIFIER = new Identifier().setSystem("abc").setValue("123"); + private static IAuthResourceResolver ourAuthResourceResolver; + + @Autowired + public void setAuthResourceResolver(IAuthResourceResolver theResourceResolver) { + ourAuthResourceResolver = theResourceResolver; + } + + @BeforeAll + public static void beforeAll(@Autowired BulkDataExportProvider theBulkExportProvider) { + ourPatientExportServer.registerProvider(theBulkExportProvider); + ourGroupExportServer.registerProvider(theBulkExportProvider); + } + + @BeforeEach + public void beforeEach() { + myStorageSettings.setEnableTaskBulkExportJobExecution(true); + ignoreCapturingBatchTableSelectQueries(); + } + + @AfterEach + public void afterEach() { + myStorageSettings.setEnableTaskBulkExportJobExecution(new JpaStorageSettings().isEnableTaskBulkExportJobExecution()); + myCaptureQueriesListener.setSelectQueryInclusionCriteria(CircularQueueCaptureQueriesListener.DEFAULT_SELECT_INCLUSION_CRITERIA); + } + + @ParameterizedTest + @MethodSource("paramsIdentifierAuthorizedUnauthorized") + public void testGroupBulkExport_exportInstance(Identifier theIdentifier) { + // Given + createPatientWithIdAndIdentifier("Patient/p1", AUTH_IDENTIFIER); + Long groupPid = createGroupWithIdentifier(theIdentifier); + + // When + myCaptureQueriesListener.clear(); + + Parameters parameters = buildExportParameters(); + performInstanceBulkExportAndAwaitCompletion(ourGroupExportServer.getFhirClient(), "Group/g1", parameters); + + // Then + assertAuthorizationSelectQueriesCaptured(List.of(groupPid)); + } + + @ParameterizedTest + @MethodSource("paramsIdentifierAuthorizedUnauthorized") + public void testPatientBulkExport_exportInstance(Identifier theIdentifier) { + // Given + Long patientPid = createPatientWithIdAndIdentifier("Patient/p1", theIdentifier); + + // When + myCaptureQueriesListener.clear(); + + Parameters parameters = buildExportParameters(); + performInstanceBulkExportAndAwaitCompletion(ourPatientExportServer.getFhirClient(), "Patient/p1", parameters); + + // Then + assertAuthorizationSelectQueriesCaptured(List.of(patientPid)); + } + + @Test + public void testPatientBulkExport_exportMultipleInstancesAuthorized() { + // Given + Long patientPid = createPatientWithIdAndIdentifier("Patient/p1", AUTH_IDENTIFIER); + Long patientPid2 = createPatientWithIdAndIdentifier("Patient/p2", AUTH_IDENTIFIER); + + // When + myCaptureQueriesListener.clear(); + + Parameters parameters = buildExportParameters("Patient/p1", "Patient/p2"); + performTypeBulkExportAndAwaitCompletion(ourPatientExportServer.getFhirClient(), "Patient", parameters); + + // Then + assertAuthorizationSelectQueriesCaptured(List.of(patientPid, patientPid2)); + } + + @ParameterizedTest + @MethodSource("paramsIdentifierAuthorizedUnauthorized") + public void testPatientBulkExport_exportType(Identifier theIdentifier) { + // Given + createPatientWithIdAndIdentifier("Patient/p1", theIdentifier); + + // When + myCaptureQueriesListener.clear(); + + Parameters parameters = buildExportParameters(); + performTypeBulkExportAndAwaitCompletion(ourPatientExportServer.getFhirClient(), "Patient", parameters); + + // Then + assertNoAuthorizationSelectQueriesCaptured(1); + } + + public static Stream paramsIdentifierAuthorizedUnauthorized() { + return Stream.of( + Arguments.of(AUTH_IDENTIFIER), + Arguments.of(UNAUTH_IDENTIFIER) + ); + } + + private Long createPatientWithIdAndIdentifier(String thePatientFhirId, Identifier theIdentifier) { + Patient p = new Patient(); + p.setId(thePatientFhirId); + p.addIdentifier(theIdentifier); + p.setActive(true); + return (Long) myPatientDao.update(p, mySrd).getPersistentId().getId(); + } + + private Long createGroupWithIdentifier(Identifier theIdentifier) { + Group group = new Group(); + group.setId("g1"); + group.addIdentifier(theIdentifier); + group.addMember().setEntity(new Reference("Patient/p1")); + return (Long) myGroupDao.update(group, mySrd).getPersistentId().getId(); + } + + private static Parameters buildExportParameters(String... theIds) { + Parameters parameters = new Parameters(); + parameters.addParameter().setName("_type").setValue(new StringType("Patient")); + parameters.addParameter().setName("_outputFormat").setValue(new StringType("application/fhir+ndjson")); + for (String id : theIds) { + parameters.addParameter().setName("patient").setValue(new StringType(id)); + } + return parameters; + } + + private void performInstanceBulkExportAndAwaitCompletion(IGenericClient theClient, String theResourceId, Parameters theParams) { + MethodOutcome outcome = theClient.operation().onInstance(theResourceId).named("$export").withParameters(theParams) + .withAdditionalHeader("Prefer", "respond-async") + .withAdditionalHeader("cache-control", "no-cache") + .returnMethodOutcome() + .execute(); + + String jobId = outcome.getFirstResponseHeader("content-location").get().split("=")[1]; + myBatch2JobHelper.awaitJobCompletion(jobId); + } + + private void performTypeBulkExportAndAwaitCompletion(IGenericClient theClient, String theResourceType, Parameters theParams) { + MethodOutcome outcome = theClient.operation().onType(theResourceType).named("$export").withParameters(theParams) + .withAdditionalHeader("Prefer", "respond-async") + .withAdditionalHeader("cache-control", "no-cache") + .returnMethodOutcome() + .execute(); + + String jobId = outcome.getFirstResponseHeader("content-location").get().split("=")[1]; + myBatch2JobHelper.awaitJobCompletion(jobId); + } + + private void assertAuthorizationSelectQueriesCaptured(List theExpectedQueriedResourcePids) { + /* + * The AuthorizationInterceptor uses a AuthResourceResolver which queries the DB for the requested Patient + * then uses a matcher to match the Patient against the permissioned query filter + * + * Breakdown expected SELECT queries: + * 2 per resource for validating the target exists before exporting (resolve PID, resolve resource) + * 2 (total) for resolving the resource for authorization (resolve PID, resolve resource) + */ + int numberOfResources = theExpectedQueriedResourcePids.size(); + + myCaptureQueriesListener.logSelectQueries(); + assertEquals(2 * numberOfResources + 2, myCaptureQueriesListener.countSelectQueries()); + + // Verify the SELECT queries the actual expected resource PID + List queries = myCaptureQueriesListener.getSelectQueries().stream().map(t -> t.getSql(true, false)).toList(); + assertThat(queries).allMatch(query -> theExpectedQueriedResourcePids.stream().map(String::valueOf).anyMatch(query::contains)); + } + + private void assertNoAuthorizationSelectQueriesCaptured(int theNumberOfResources) { + // Expect only 2 select queries per resource for + // validating the target exists before exporting (resolve PID, resolve resource) + myCaptureQueriesListener.logSelectQueries(); + assertEquals(2 * theNumberOfResources , myCaptureQueriesListener.countSelectQueries()); + } + + /** + * Ignore capturing SELECT queries to the batch tables (BT2_...) + */ + private void ignoreCapturingBatchTableSelectQueries() { + myCaptureQueriesListener.setSelectQueryInclusionCriteria(CircularQueueCaptureQueriesListener.DEFAULT_SELECT_INCLUSION_CRITERIA.and(t -> !t.contains("BT2_"))); + } + + /** + * Build an AuthorizationInterceptor with permissions to export by filter and export type (Group/Patient) + * @param theExportType the export type (Group/Patient) + * @return AuthorizationInterceptor with appropriate permissions to export + */ + private static AuthorizationInterceptor buildAuthInterceptorWithPatientExportByFilterPermissions(BulkExportJobParameters.ExportStyle theExportType) { + Class resourceType = theExportType.equals(BulkExportJobParameters.ExportStyle.PATIENT) ? Patient.class : Group.class; + return new AuthorizationInterceptor(PolicyEnum.ALLOW) { + @Override + public List buildRuleList(RequestDetails theRequestDetails) { + RuleBuilder ruleBuilder = new RuleBuilder(); + + // Allow export with filter rule + IAuthRuleBuilderRuleBulkExport bulkExportWithFilterRuleBuilder = ruleBuilder.allow() + .bulkExport(); + + if (theExportType.equals(BulkExportJobParameters.ExportStyle.PATIENT)) { + bulkExportWithFilterRuleBuilder.patientExportOnFilter("?identifier=foo|bar").andThen(); + } else { + bulkExportWithFilterRuleBuilder.groupExportOnFilter("?identifier=foo|bar").andThen(); + } + + // Allow running type export operation on Patients + ruleBuilder.allow() + .operation() + .named(ProviderConstants.OPERATION_EXPORT) + .onType(resourceType) + .andAllowAllResponses() + .andThen(); + + // Allow running instance export operation on Patients + ruleBuilder.allow() + .operation() + .named(ProviderConstants.OPERATION_EXPORT) + .onInstancesOfType(resourceType) + .andAllowAllResponses() + .andThen(); + + // Allow polling export status + ruleBuilder.allow() + .operation() + .named(ProviderConstants.OPERATION_EXPORT_POLL_STATUS) + .onServer() + .andAllowAllResponses() + .andThen(); + + return ruleBuilder.build(); + } + + @Override + public IAuthResourceResolver getAuthResourceResolver() { + return ourAuthResourceResolver; + } + }; + } + + /** + * Mock configuration class with empty steps that overrides the definition of a bulk export job. + * This allows us to analyze the query counts for the work before the bulk export runs without actually running + * bulk export. If bulk export starts before getting the query count, then there could be extra queries counted. + */ + @Configuration + public static class MockBulkExportJobConfig extends BulkExportAppCtx { + @Bean + @Override + public JobDefinition bulkExportJobDefinition() { + JobDefinition.Builder builder = JobDefinition.newBuilder(); + builder.setJobDefinitionId(Batch2JobDefinitionConstants.BULK_EXPORT); + builder.setJobDescription("Mock FHIR Bulk Export"); + builder.setJobDefinitionVersion(1); + + return builder.setParametersType(BulkExportJobParameters.class) + // validator + .setParametersValidator(bulkExportJobParametersValidator()) + .gatedExecution() + .addFirstStep("stepA", "mock step A", ResourceIdList.class, new MockStepA()) + .addLastStep("stepB", "mock step B", new MockStepB()) + .build(); + } + + @Bean + @Override + public JobDefinition bulkExportJobV2Definition() { + JobDefinition.Builder builder = JobDefinition.newBuilder(); + builder.setJobDefinitionId(Batch2JobDefinitionConstants.BULK_EXPORT); + builder.setJobDescription("Mock FHIR Bulk Export"); + builder.setJobDefinitionVersion(2); + + return builder.setParametersType(BulkExportJobParameters.class) + // validator + .setParametersValidator(bulkExportJobParametersValidator()) + .gatedExecution() + .addFirstStep("stepA", "mock step A", ResourceIdList.class, new MockStepA()) + .addLastStep("stepB", "mock step B", new MockStepB()) + .build(); + } + + // Mock steps that do nothing. + // At least 2 steps are required to define a job. + private static class MockStepA implements IFirstJobStepWorker { + @Nonnull + @Override + public RunOutcome run(@Nonnull StepExecutionDetails theStepExecutionDetails, @Nonnull IJobDataSink theDataSink) throws JobExecutionFailedException { + return new RunOutcome(1); + } + } + + private static class MockStepB implements IJobStepWorker { + @Nonnull + @Override + public RunOutcome run(@Nonnull StepExecutionDetails theStepExecutionDetails, @Nonnull IJobDataSink theDataSink) throws JobExecutionFailedException { + return new RunOutcome(1); + } + } + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRule.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRule.java index 5ce7e1529342..249ac7b37973 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRule.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRule.java @@ -93,6 +93,31 @@ private boolean applyTesters( return retVal; } + /** + * Apply testers, and return true if at least 1 tester matches. + * Returns false if all testers do not match. + */ + boolean atLeastOneTesterMatches( + RestOperationTypeEnum theOperation, + RequestDetails theRequestDetails, + IBaseResource theInputResource, + IRuleApplier theRuleApplier) { + + boolean retVal = false; + + IAuthRuleTester.RuleTestRequest inputRequest = new IAuthRuleTester.RuleTestRequest( + myMode, theOperation, theRequestDetails, null, theInputResource, theRuleApplier); + + for (IAuthRuleTester next : getTesters()) { + if (next.matches(inputRequest)) { + retVal = true; + break; + } + } + + return retVal; + } + PolicyEnum getMode() { return myMode; } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRuleBulkExportByCompartmentMatcher.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRuleBulkExportByCompartmentMatcher.java new file mode 100644 index 000000000000..7564f6d74db6 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/BaseRuleBulkExportByCompartmentMatcher.java @@ -0,0 +1,97 @@ +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2025 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.rest.server.interceptor.auth; + +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; +import com.google.common.annotations.VisibleForTesting; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; + +import java.util.Collection; +import java.util.Set; + +import static ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS; +import static org.apache.commons.collections4.CollectionUtils.isEmpty; +import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; + +abstract class BaseRuleBulkExportByCompartmentMatcher extends BaseRule { + private final BulkExportJobParameters.ExportStyle myExportStyle; + private Collection myResourceTypes; + + BaseRuleBulkExportByCompartmentMatcher(String theRuleName, BulkExportJobParameters.ExportStyle theExportStyle) { + super(theRuleName); + myExportStyle = theExportStyle; + } + + @Override + public AuthorizationInterceptor.Verdict applyRule( + RestOperationTypeEnum theOperation, + RequestDetails theRequestDetails, + IBaseResource theInputResource, + IIdType theInputResourceId, + IBaseResource theOutputResource, + IRuleApplier theRuleApplier, + Set theFlags, + Pointcut thePointcut) { + if (thePointcut != Pointcut.STORAGE_INITIATE_BULK_EXPORT) { + return null; + } + + if (theRequestDetails == null) { + return null; + } + + BulkExportJobParameters inboundBulkExportRequestOptions = (BulkExportJobParameters) + theRequestDetails.getUserData().get(REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS); + + if (inboundBulkExportRequestOptions.getExportStyle() != myExportStyle) { + // If the requested export style is not the implied style by the implementer, then abstain + return null; + } + + // Do we only authorize some types? If so, make sure requested types are a subset + if (isNotEmpty(myResourceTypes)) { + if (isEmpty(inboundBulkExportRequestOptions.getResourceTypes())) { + // Attempting an export on ALL resource types, but this rule restricts on a set of resource types + return new AuthorizationInterceptor.Verdict(PolicyEnum.DENY, this); + } + if (!myResourceTypes.containsAll(inboundBulkExportRequestOptions.getResourceTypes())) { + // The requested resource types is not a subset of the permitted resource types + return new AuthorizationInterceptor.Verdict(PolicyEnum.DENY, this); + } + } + + // We passed the first few checks, so we'll ALLOW for now... + // Further checks are required by the implementation. + return new AuthorizationInterceptor.Verdict(PolicyEnum.ALLOW, this); + } + + public void setResourceTypes(Collection theResourceTypes) { + myResourceTypes = theResourceTypes; + } + + @VisibleForTesting + Collection getResourceTypes() { + return myResourceTypes; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthResourceResolver.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthResourceResolver.java new file mode 100644 index 000000000000..2fe7909ccd07 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthResourceResolver.java @@ -0,0 +1,41 @@ +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2025 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.rest.server.interceptor.auth; + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; + +import java.util.List; + +/** + * Small service class to inject DB access into an interceptor + * For example, used in bulk export security to allow querying for resource to match against permission argument filters + */ +public interface IAuthResourceResolver { + IBaseResource resolveResourceById(IIdType theResourceId); + + /** + * Resolve a list of resources by ID. All resources should be the same type. + * @param theResourceIds the FHIR id of the resource(s) + * @param theResourceType the type of resource + * @return A list of resources resolved by ID + */ + List resolveResourcesByIds(List theResourceIds, String theResourceType); +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthRuleBuilderRuleBulkExport.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthRuleBuilderRuleBulkExport.java index 1719cd0c8632..f69ce28c3c52 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthRuleBuilderRuleBulkExport.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IAuthRuleBuilderRuleBulkExport.java @@ -46,6 +46,24 @@ default IAuthRuleBuilderRuleBulkExportWithTarget groupExportOnGroup(@Nonnull IId */ IAuthRuleBuilderRuleBulkExportWithTarget groupExportOnGroup(@Nonnull String theFocusResourceId); + /** + * Allow/deny group-level export rule applies to the Group by matching on the provided FHIR query filter, + * e.g. ?identifier=foo|bar + * Note that resource type is implied to be Group + * + * @since 8.6.0 + */ + IAuthRuleBuilderRuleBulkExportWithTarget groupExportOnFilter(@Nonnull String theCompartmentFilterMatcher); + + /** + * Allow/deny patient-level export rule applies to the Patient by matching on the provided FHIR query filter, + * e.g. ?identifier=foo|bar + * Note that resource type is implied to be Patient + * + * @since 8.6.0 + */ + IAuthRuleBuilderRuleBulkExportWithTarget patientExportOnFilter(@Nonnull String theCompartmentFilterMatcher); + /** * Allow/deny patient-level export rule applies to the Group with the given resource ID, e.g. Group/123 * diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IRuleApplier.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IRuleApplier.java index 8bfc67ad7274..1b2a8b29a6c3 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IRuleApplier.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/IRuleApplier.java @@ -50,5 +50,15 @@ Verdict applyRulesAndReturnDecision( default IAuthorizationSearchParamMatcher getSearchParamMatcher() { return null; } - ; + + /** + * WARNING: This is slow, and should have limited use in authorization. + * The auth resource resolve is a service that allows you to query the DB for a resource, given a resource ID. + * + * It is currently used for bulk-export, to support permissible Group/Patient exports by matching a FHIR query. + * This is ok, since bulk-export is a slow and (relatively) rare operation. + */ + default IAuthResourceResolver getAuthResourceResolver() { + return null; + } } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilder.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilder.java index 4e444c09956c..c341a4aff0d3 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilder.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilder.java @@ -259,7 +259,7 @@ private class RuleBuilderRule implements IAuthRuleBuilderRule { private final String myRuleName; private RuleBuilderRuleOp myReadRuleBuilder; private RuleBuilderRuleOp myWriteRuleBuilder; - private RuleBuilderBulkExport ruleBuilderBulkExport; + private RuleBuilderBulkExport myRuleBuilderBulkExport; RuleBuilderRule(PolicyEnum theRuleMode, String theRuleName) { myRuleMode = theRuleMode; @@ -341,10 +341,10 @@ public IAuthRuleBuilderGraphQL graphQL() { @Override public IAuthRuleBuilderRuleBulkExport bulkExport() { - if (ruleBuilderBulkExport == null) { - ruleBuilderBulkExport = new RuleBuilderBulkExport(); + if (myRuleBuilderBulkExport == null) { + myRuleBuilderBulkExport = new RuleBuilderBulkExport(); } - return ruleBuilderBulkExport; + return myRuleBuilderBulkExport; } @Override @@ -888,6 +888,8 @@ public IAuthRuleFinished any() { private class RuleBuilderBulkExport implements IAuthRuleBuilderRuleBulkExport { private RuleBulkExportImpl myRuleBulkExport; + private RuleGroupBulkExportByCompartmentMatcherImpl myRuleGroupBulkExportByCompartmentMatcher; + private RulePatientBulkExportByCompartmentMatcherImpl myRulePatientBulkExportByCompartmentMatcher; @Override public IAuthRuleBuilderRuleBulkExportWithTarget groupExportOnGroup(@Nonnull String theFocusResourceId) { @@ -985,6 +987,51 @@ public IAuthRuleBuilderRuleBulkExportWithTarget any() { return new RuleBuilderBulkExportWithTarget(rule); } + @Override + public IAuthRuleBuilderRuleBulkExportWithTarget groupExportOnFilter( + @Nonnull String theCompartmentFilterMatcher) { + if (myRuleGroupBulkExportByCompartmentMatcher == null) { + RuleGroupBulkExportByCompartmentMatcherImpl rule = + new RuleGroupBulkExportByCompartmentMatcherImpl(myRuleName); + rule.setAppliesToGroupExportOnGroup(theCompartmentFilterMatcher); + rule.setMode(myRuleMode); + myRuleGroupBulkExportByCompartmentMatcher = rule; + } else { + myRuleGroupBulkExportByCompartmentMatcher.setAppliesToGroupExportOnGroup( + theCompartmentFilterMatcher); + } + + // prevent duplicate rules from being added + if (!myRules.contains(myRuleGroupBulkExportByCompartmentMatcher)) { + myRules.add(myRuleGroupBulkExportByCompartmentMatcher); + } + + return new RuleBuilderBulkExportWithFilter(myRuleGroupBulkExportByCompartmentMatcher); + } + + @Override + public IAuthRuleBuilderRuleBulkExportWithTarget patientExportOnFilter( + @Nonnull String theCompartmentFilterMatcher) { + if (myRulePatientBulkExportByCompartmentMatcher == null) { + RulePatientBulkExportByCompartmentMatcherImpl rule = + new RulePatientBulkExportByCompartmentMatcherImpl(myRuleName); + + rule.addAppliesToPatientExportOnPatient(theCompartmentFilterMatcher); + rule.setMode(myRuleMode); + myRulePatientBulkExportByCompartmentMatcher = rule; + } else { + myRulePatientBulkExportByCompartmentMatcher.addAppliesToPatientExportOnPatient( + theCompartmentFilterMatcher); + } + + // prevent duplicate rules from being added + if (!myRules.contains(myRulePatientBulkExportByCompartmentMatcher)) { + myRules.add(myRulePatientBulkExportByCompartmentMatcher); + } + + return new RuleBuilderBulkExportWithFilter(myRulePatientBulkExportByCompartmentMatcher); + } + private class RuleBuilderBulkExportWithTarget extends RuleBuilderFinished implements IAuthRuleBuilderRuleBulkExportWithTarget { private final RuleBulkExportImpl myRule; @@ -1000,6 +1047,22 @@ public IAuthRuleBuilderRuleBulkExportWithTarget withResourceTypes(Collection theResourceTypes) { + myRule.setResourceTypes(theResourceTypes); + return this; + } + } } } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImpl.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImpl.java index 2c8ab32974ff..2c9f7742e360 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImpl.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImpl.java @@ -34,6 +34,7 @@ import java.util.Set; import java.util.stream.Collectors; +import static ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS; import static org.apache.commons.collections4.CollectionUtils.isEmpty; import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; import static org.apache.commons.lang3.StringUtils.isNotBlank; @@ -70,9 +71,8 @@ public AuthorizationInterceptor.Verdict applyRule( return null; } - BulkExportJobParameters inboundBulkExportRequestOptions = (BulkExportJobParameters) theRequestDetails - .getUserData() - .get(AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS); + BulkExportJobParameters inboundBulkExportRequestOptions = (BulkExportJobParameters) + theRequestDetails.getUserData().get(REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS); // if style doesn't match - abstain if (!myWantAnyStyle && inboundBulkExportRequestOptions.getExportStyle() != myWantExportStyle) { return null; @@ -120,11 +120,12 @@ public AuthorizationInterceptor.Verdict applyRule( // 1. If each of the requested resource IDs in the parameters are present in the users permissions, Approve // 2. If any requested ID is not present in the users permissions, Deny. - if (myWantExportStyle == BulkExportJobParameters.ExportStyle.PATIENT) + if (myWantExportStyle == BulkExportJobParameters.ExportStyle.PATIENT) { // Unfiltered Type Level if (myAppliesToAllPatients) { return allowVerdict; } + } // Instance level, or filtered type level if (isNotEmpty(myPatientIds)) { diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImpl.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImpl.java new file mode 100644 index 000000000000..33ce13df0a0b --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImpl.java @@ -0,0 +1,94 @@ +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2025 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.rest.server.interceptor.auth; + +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; +import ca.uhn.fhir.util.UrlUtil; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; + +import java.util.*; + +import static ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS; + +public class RuleGroupBulkExportByCompartmentMatcherImpl extends BaseRuleBulkExportByCompartmentMatcher { + private String myGroupMatcherFilter; + + RuleGroupBulkExportByCompartmentMatcherImpl(String theRuleName) { + super(theRuleName, BulkExportJobParameters.ExportStyle.GROUP); + } + + @Override + public AuthorizationInterceptor.Verdict applyRule( + RestOperationTypeEnum theOperation, + RequestDetails theRequestDetails, + IBaseResource theInputResource, + IIdType theInputResourceId, + IBaseResource theOutputResource, + IRuleApplier theRuleApplier, + Set theFlags, + Pointcut thePointcut) { + // Apply the base checks for invalid inputs, requested resource types + AuthorizationInterceptor.Verdict result = super.applyRule( + theOperation, + theRequestDetails, + theInputResource, + theInputResourceId, + theOutputResource, + theRuleApplier, + theFlags, + thePointcut); + if (result == null || result.getDecision().equals(PolicyEnum.DENY)) { + // The base checks have already decided we should abstain, or deny + return result; + } + + BulkExportJobParameters inboundBulkExportRequestOptions = (BulkExportJobParameters) + theRequestDetails.getUserData().get(REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS); + + IBaseResource theGroupResource = theRuleApplier + .getAuthResourceResolver() + .resolveResourceById(new IdDt(inboundBulkExportRequestOptions.getGroupId())); + + // Apply the FhirQueryTester (which contains a inMemoryResourceMatcher) to the found Group compartment resource, + // and return the verdict + return newVerdict( + theOperation, + theRequestDetails, + theGroupResource, + theInputResourceId, + theOutputResource, + theRuleApplier); + } + + public void setAppliesToGroupExportOnGroup(String theGroupMatcherFilter) { + String sanitizedFilter = UrlUtil.parseUrl(theGroupMatcherFilter).getParams(); + myGroupMatcherFilter = sanitizedFilter; + addTester(new FhirQueryRuleTester(sanitizedFilter)); + } + + public String getGroupMatcherFilter() { + return myGroupMatcherFilter; + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImpl.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImpl.java new file mode 100644 index 000000000000..a86e6fab76cb --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImpl.java @@ -0,0 +1,211 @@ +/*- + * #%L + * HAPI FHIR - Server Framework + * %% + * Copyright (C) 2014 - 2025 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.rest.server.interceptor.auth; + +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; +import ca.uhn.fhir.rest.server.util.MatchUrlUtil; +import ca.uhn.fhir.util.UrlUtil; +import com.google.common.annotations.VisibleForTesting; +import org.apache.http.NameValuePair; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS; +import static ca.uhn.fhir.rest.server.interceptor.auth.PolicyEnum.ALLOW; +import static ca.uhn.fhir.rest.server.interceptor.auth.PolicyEnum.DENY; + +public class RulePatientBulkExportByCompartmentMatcherImpl extends BaseRuleBulkExportByCompartmentMatcher { + private List myPatientMatcherFilter; + private List> myTokenizedPatientMatcherFilter; + + RulePatientBulkExportByCompartmentMatcherImpl(String theRuleName) { + super(theRuleName, BulkExportJobParameters.ExportStyle.PATIENT); + } + + @Override + public AuthorizationInterceptor.Verdict applyRule( + RestOperationTypeEnum theOperation, + RequestDetails theRequestDetails, + IBaseResource theInputResource, + IIdType theInputResourceId, + IBaseResource theOutputResource, + IRuleApplier theRuleApplier, + Set theFlags, + Pointcut thePointcut) { + // Apply the base checks for invalid inputs, requested resource types + AuthorizationInterceptor.Verdict result = super.applyRule( + theOperation, + theRequestDetails, + theInputResource, + theInputResourceId, + theOutputResource, + theRuleApplier, + theFlags, + thePointcut); + if (result == null || result.getDecision().equals(PolicyEnum.DENY)) { + // The base checks have already decided we should abstain, or deny + return result; + } + + BulkExportJobParameters inboundBulkExportRequestOptions = (BulkExportJobParameters) + theRequestDetails.getUserData().get(REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS); + + List patientIdOptions = inboundBulkExportRequestOptions.getPatientIds(); + List filterOptions = inboundBulkExportRequestOptions.getFilters(); + + if (!filterOptions.isEmpty()) { + // Export with a _typeFilter + boolean allFiltersMatch = matchOnFilterOptions(filterOptions); + + if (patientIdOptions.isEmpty()) { + // This is a type-level export with a _typeFilter + // All filters must be permitted to return an ALLOW verdict + return allFiltersMatch + ? new AuthorizationInterceptor.Verdict(ALLOW, this) + : new AuthorizationInterceptor.Verdict(DENY, this); + } else if (!allFiltersMatch) { + // This is an instance-level export with a _typeFilter + // Where at least one filter didn't match the permitted filters + return new AuthorizationInterceptor.Verdict(DENY, this); + } + } + + List patients = + theRuleApplier.getAuthResourceResolver().resolveResourcesByIds(patientIdOptions, "Patient"); + + return applyTestersToPatientResources(theOperation, theRequestDetails, theRuleApplier, patients); + } + + /** + * Applies the testers (via OR - at least one matches) to the list of Patient resources, and returns a verdict. + * + * @param theOperation the operation type + * @param theRequestDetails the request details + * @param theRuleApplier the rule applier + * @param thePatientResources the list of patient resources to apply the testers to + * @return Cases: + *
    + *
  • null/abstain: If the list of patient resources is empty + *
  • null/abstain: If all Patients evaluate to NO match + *
  • DENY: If some Patients evaluate to match, while other Patients evaluate to NO match + *
  • ALLOW: If all Patients evaluate to match + *
+ */ + private AuthorizationInterceptor.Verdict applyTestersToPatientResources( + RestOperationTypeEnum theOperation, + RequestDetails theRequestDetails, + IRuleApplier theRuleApplier, + List thePatientResources) { + // Apply the FhirQueryTester (which contains a inMemoryResourceMatcher) to the found Patient compartment + // resource, + // and return the verdict + // All requested Patient IDs must be permitted to return an ALLOW verdict. + + boolean atLeastOnePatientMatchesOnTesters = false; + boolean atLeastOnePatientDoesNotMatchOnTesters = false; + + for (IBaseResource patient : thePatientResources) { + boolean applies = atLeastOneTesterMatches(theOperation, theRequestDetails, patient, theRuleApplier); + + if (applies) { + atLeastOnePatientMatchesOnTesters = true; + } else { + atLeastOnePatientDoesNotMatchOnTesters = true; + } + + if (atLeastOnePatientMatchesOnTesters && atLeastOnePatientDoesNotMatchOnTesters) { + // Then the testers evaluated to true on some Patients, and false on others - no need to evaluate the + // rest + // We have a mixture of ALLOW and abstain + // Default to DENY + return new AuthorizationInterceptor.Verdict(PolicyEnum.DENY, this); + } + } + + // If all testers evaluated to match, then ALLOW. If they all evaluated to false, then abstain. + // It's impossible for both atLeastOneTesterApplied=true and atLeastOneTesterDoesNotApplied=true + // due to the early-return in the for loop + return atLeastOnePatientMatchesOnTesters ? new AuthorizationInterceptor.Verdict(PolicyEnum.ALLOW, this) : null; + } + + /** + * See if ALL the requested _typeFilters match at least one of the permitted filters as defined in the permission. + * + * In order for the export to be allowed, at least one permission argument filter must exactly match all search parameters included in the query + * The search parameters in the filters are tokenized so that parameter ordering does not matter + * + * Example 1: Patient?name=Doe&active=true == Patient?active=true&name=Doe + * Example 2: Patient?name=Doe != Patient?active=true&name=Doe + * + * @param theFilterOptions The inbound export _typeFilter options. + * As per the spec, these filters should have a resource type. + * (https://build.fhir.org/ig/HL7/bulk-data/en/export.html#_typefilter-query-parameter) + * + * @return true if the all _typeFilters are permitted, false otherwise + */ + private boolean matchOnFilterOptions(List theFilterOptions) { + for (String filter : theFilterOptions) { + String query = UrlUtil.parseUrl(filter).getParams(); + Set tokenizedQuery = new HashSet<>(MatchUrlUtil.translateMatchUrl(query)); + + if (!myTokenizedPatientMatcherFilter.contains(tokenizedQuery)) { + return false; + } + } + return true; + } + + /** + * @param thePatientMatcherFilter the matcher filter for the permitted Patient + */ + public void addAppliesToPatientExportOnPatient(String thePatientMatcherFilter) { + + if (myPatientMatcherFilter == null) { + myPatientMatcherFilter = new ArrayList<>(); + } + + String sanitizedFilter = UrlUtil.parseUrl(thePatientMatcherFilter).getParams(); + myPatientMatcherFilter.add(sanitizedFilter); + addTester(new FhirQueryRuleTester(sanitizedFilter)); + + if (myTokenizedPatientMatcherFilter == null) { + myTokenizedPatientMatcherFilter = new ArrayList<>(); + } + + myTokenizedPatientMatcherFilter.add(new HashSet<>(MatchUrlUtil.translateMatchUrl(sanitizedFilter))); + } + + public List getPatientMatcherFilters() { + return myPatientMatcherFilter; + } + + @VisibleForTesting + public List> getTokenizedPatientMatcherFilter() { + return myTokenizedPatientMatcherFilter; + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilderTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilderTest.java index 9f9d01ee2ac4..70e7189fa51f 100644 --- a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilderTest.java +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBuilderTest.java @@ -84,6 +84,71 @@ public void testBulkExportPermitsIfASingleGroupMatches() { } + @ParameterizedTest + @MethodSource("groupMatcherBulkExportParams") + public void testBulkExportByGroupMatcher(String theCompartmentMatcherFilter, Collection theResourceTypes) { + // Given + RuleBuilder builder = new RuleBuilder(); + List resourceTypes = new ArrayList<>(theResourceTypes); + + // When + builder.allow().bulkExport().groupExportOnFilter("?" + theCompartmentMatcherFilter).withResourceTypes(resourceTypes); + final List rules = builder.build(); + + // Then + assertEquals(1, rules.size()); + final IAuthRule authRule = rules.get(0); + assertInstanceOf(RuleGroupBulkExportByCompartmentMatcherImpl.class, authRule); + + final RuleGroupBulkExportByCompartmentMatcherImpl ruleGroupBulkExport = (RuleGroupBulkExportByCompartmentMatcherImpl) authRule; + assertEquals(theCompartmentMatcherFilter, ruleGroupBulkExport.getGroupMatcherFilter()); + assertEquals(theResourceTypes, ruleGroupBulkExport.getResourceTypes()); + assertEquals(PolicyEnum.ALLOW, ruleGroupBulkExport.getMode()); + } + + private static Stream groupMatcherBulkExportParams() { + return Stream.of( + Arguments.of("identifier=foo|bar", List.of()), + Arguments.of("identifier=foo|bar", List.of("Patient", "Observation")) + ); + } + + @ParameterizedTest + @MethodSource("patientMatcherBulkExportParams") + public void testBulkExportByPatientMatcher(List theCompartmentMatcherFilter, Collection theResourceTypes) { + // Given + RuleBuilder builder = new RuleBuilder(); + List resourceTypes = new ArrayList<>(theResourceTypes); + + // When + for (String filter : theCompartmentMatcherFilter) { + builder.allow().bulkExport().patientExportOnFilter("?" + filter).withResourceTypes(resourceTypes); + } + final List rules = builder.build(); + + // Then + assertEquals(1, rules.size()); + final IAuthRule authRule = rules.get(0); + assertInstanceOf(RulePatientBulkExportByCompartmentMatcherImpl.class, authRule); + + final RulePatientBulkExportByCompartmentMatcherImpl rulePatientExport = (RulePatientBulkExportByCompartmentMatcherImpl) authRule; + assertThat(rulePatientExport.getPatientMatcherFilters()).containsExactlyInAnyOrderElementsOf(theCompartmentMatcherFilter); + assertEquals(theResourceTypes, rulePatientExport.getResourceTypes()); + assertEquals(PolicyEnum.ALLOW, rulePatientExport.getMode()); + } + + private static Stream patientMatcherBulkExportParams() { + return Stream.of( + Arguments.of(List.of("identifier=foo|bar"), List.of()), + Arguments.of(List.of("identifier=foo|bar"), List.of("Patient", "Observation")), + // Multiple arguments may be added to the filter when multiple FHIR_OP_INITIATE_BULK_DATA_EXPORT_PATIENTS_MATCHING permissions + // are added to the same user, even when the permission does not accept multiple (a list of) arguments by itself. + Arguments.of(List.of("identifier=foo|bar", "name=Doe"), List.of()), + Arguments.of(List.of("identifier=foo|bar", "name=Doe&active=true"), List.of("Patient", "Observation")), + Arguments.of(List.of("identifier=foo|bar", "active=true&name=Doe"), List.of("Patient", "Observation")) + ); + } + @Test public void testBulkExport_PatientExportOnPatient_MultiplePatientsSingleRule() { RuleBuilder builder = new RuleBuilder(); diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImplTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImplTest.java index b5e96a7eae65..a0fed1081628 100644 --- a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImplTest.java +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleBulkExportImplTest.java @@ -4,6 +4,7 @@ import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -550,6 +551,7 @@ public void testPatientExportRulesOnTypeLevelExportWithPermittedAndUnpermittedPa final BulkExportJobParameters options = new BulkExportJobParameters(); options.setExportStyle(BulkExportJobParameters.ExportStyle.PATIENT); options.setFilters(Set.of("Patient?_id=123","Patient?_id=456")); + options.setResourceTypes(Set.of("Patient")); when(myRequestDetails.getUserData()).thenReturn(Map.of(AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS, options)); diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImplTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImplTest.java new file mode 100644 index 000000000000..7ec4e44f71b0 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImplTest.java @@ -0,0 +1,103 @@ +package ca.uhn.fhir.rest.server.interceptor.auth; + +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; + +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters.ExportStyle; + +import java.util.Collection; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.hl7.fhir.instance.model.api.IBaseResource; + +import org.junit.jupiter.api.Assertions; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; + +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.mockito.ArgumentMatchers.any; + +import org.mockito.Mock; + +import static org.mockito.Mockito.when; + +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RuleGroupBulkExportByCompartmentMatcherImplTest { + @Mock + private IRuleApplier myRuleApplier; + @Mock + private IAuthResourceResolver myAuthResourceResolver; + @Mock + private IBaseResource myResource; + @Mock + private IAuthorizationSearchParamMatcher mySearchParamMatcher; + @Mock + private RequestDetails myRequestDetails; + + @ParameterizedTest + @MethodSource("params") + void testGroupRule_withCompartmentMatchers(IAuthorizationSearchParamMatcher.MatchResult theSearchParamMatcherMatchResult, Collection theAllowedResourceTypes, ExportStyle theExportStyle, Collection theRequestedResourceTypes, PolicyEnum theExpectedVerdict, String theMessage) { + RuleGroupBulkExportByCompartmentMatcherImpl rule = new RuleGroupBulkExportByCompartmentMatcherImpl("b"); + rule.setAppliesToGroupExportOnGroup("?identifier=foo|bar"); + rule.setResourceTypes(theAllowedResourceTypes); + rule.setMode(PolicyEnum.ALLOW); + + BulkExportJobParameters options = new BulkExportJobParameters(); + options.setExportStyle(theExportStyle); + options.setResourceTypes(theRequestedResourceTypes); + options.setGroupId("Group/G1"); + + when(myRequestDetails.getUserData()).thenReturn(Map.of(AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS, options)); + + if (theSearchParamMatcherMatchResult != null) { + when(myRuleApplier.getAuthResourceResolver()).thenReturn(myAuthResourceResolver); + when(myAuthResourceResolver.resolveResourceById(any())).thenReturn(myResource); + when(myRuleApplier.getSearchParamMatcher()).thenReturn(mySearchParamMatcher); + when(myResource.fhirType()).thenReturn("Group"); + when(mySearchParamMatcher.match("Group?identifier=foo|bar", myResource)).thenReturn(theSearchParamMatcherMatchResult); + } + + AuthorizationInterceptor.Verdict verdict = rule.applyRule(RestOperationTypeEnum.EXTENDED_OPERATION_SERVER, myRequestDetails, null, null, null, myRuleApplier, Set.of(), Pointcut.STORAGE_INITIATE_BULK_EXPORT); + + if (theExpectedVerdict != null) { + // Expect a decision + Assertions.assertNotNull(verdict, "Expected " + theExpectedVerdict + " but got abstain - " + theMessage); + assertEquals(theExpectedVerdict, verdict.getDecision(), "Expected " + theExpectedVerdict + " but got " + verdict.getDecision() + " - " + theMessage); + } else { + // Expect abstain + assertNull(verdict, "Expected abstain - " + theMessage); + } + } + + static Stream params() { + IAuthorizationSearchParamMatcher.MatchResult match = IAuthorizationSearchParamMatcher.MatchResult.buildMatched(); + IAuthorizationSearchParamMatcher.MatchResult noMatch = IAuthorizationSearchParamMatcher.MatchResult.buildUnmatched(); + + return Stream.of( + // theSearchParamMatcherMatchResult, theAllowedResourceTypes, theExportStyle, theRequestedResourceTypes, theExpectedDecision, theMessage + Arguments.of(match, List.of(), ExportStyle.GROUP, List.of(), PolicyEnum.ALLOW, "Allow request for all types, allow all types"), + Arguments.of(match, List.of(), ExportStyle.GROUP, List.of("Patient", "Observation"), PolicyEnum.ALLOW, "Allow request for some types, allow all types"), + Arguments.of(match, List.of("Patient", "Observation"), ExportStyle.GROUP, List.of("Patient", "Observation"), PolicyEnum.ALLOW, "Allow request for exact set of allowable types"), + Arguments.of(match, List.of("Patient", "Observation"), ExportStyle.GROUP, List.of("Patient"), PolicyEnum.ALLOW, "Allow request for subset of allowable types"), + Arguments.of(noMatch, List.of("Patient", "Observation"), ExportStyle.GROUP, List.of("Patient", "Observation"), null, "Abstain when requesting some resource types, but no resources match the permission query"), + Arguments.of(noMatch, List.of(), ExportStyle.GROUP, List.of(), null, "Abstain when requesting all resource types, but no resources match the permission query"), + // The case below is the narrowing case. Narrowing should happen at the SecurityInterceptor layer + Arguments.of(null, List.of("Patient", "Observation"), ExportStyle.GROUP, List.of(), PolicyEnum.DENY, "Deny request for all types when allowing some types"), + Arguments.of(null, List.of("Patient", "Observation"), ExportStyle.GROUP, List.of("Patient", "Observation", "Encounter"), PolicyEnum.DENY, "Deny request for superset of allowable types"), + Arguments.of(null, List.of("Patient", "Observation"), ExportStyle.PATIENT, List.of(), null, "Abstain when export style is not Group") + ); + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImplTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImplTest.java new file mode 100644 index 000000000000..b941e9506ae8 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImplTest.java @@ -0,0 +1,211 @@ +package ca.uhn.fhir.rest.server.interceptor.auth; + +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters; + +import static ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters.ExportStyle.*; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.junit.jupiter.api.Assertions; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.mockito.ArgumentMatchers.any; + +import static org.mockito.ArgumentMatchers.eq; + +import org.mockito.Mock; + +import static org.mockito.Mockito.when; + +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RulePatientBulkExportByCompartmentMatcherImplTest { + @Mock + private IRuleApplier myRuleApplier; + @Mock + private IAuthResourceResolver myAuthResourceResolver; + @Mock + private IBaseResource myResource; + @Mock + private IBaseResource myResource2; + @Mock + private IAuthorizationSearchParamMatcher mySearchParamMatcher; + @Mock + private RequestDetails myRequestDetails; + + + @ParameterizedTest + @MethodSource("paramsInstanceLevel") + void testPatientRule_instanceLevelExport_withCompartmentMatchers(Collection theAllowedResourceTypes, + BulkExportJobParameters theBulkExportJobParams, + List theSearchParamMatcherMatchResults, + PolicyEnum theExpectedVerdict, + String theMessage) { + RulePatientBulkExportByCompartmentMatcherImpl rule = new RulePatientBulkExportByCompartmentMatcherImpl("b"); + rule.addAppliesToPatientExportOnPatient("?identifier=foo|bar"); + rule.setResourceTypes(theAllowedResourceTypes); + rule.setMode(PolicyEnum.ALLOW); + + when(myRequestDetails.getUserData()).thenReturn(Map.of(AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS, theBulkExportJobParams)); + + if (theSearchParamMatcherMatchResults != null) { + when(myRuleApplier.getAuthResourceResolver()).thenReturn(myAuthResourceResolver); + if (theSearchParamMatcherMatchResults.size() == 1) { + when(myAuthResourceResolver.resolveResourcesByIds(any(), eq("Patient"))).thenReturn(List.of(myResource)); + } else if (theSearchParamMatcherMatchResults.size() == 2) { + when(myAuthResourceResolver.resolveResourcesByIds(any(), eq("Patient"))).thenReturn(List.of(myResource, myResource2)); + when(myResource2.fhirType()).thenReturn("Patient"); + when(mySearchParamMatcher.match("Patient?identifier=foo|bar", myResource2)).thenReturn(theSearchParamMatcherMatchResults.get(1)); + } + when(myRuleApplier.getSearchParamMatcher()).thenReturn(mySearchParamMatcher); + when(myResource.fhirType()).thenReturn("Patient"); + when(mySearchParamMatcher.match("Patient?identifier=foo|bar", myResource)).thenReturn(theSearchParamMatcherMatchResults.get(0)); + } + + AuthorizationInterceptor.Verdict verdict = rule.applyRule(RestOperationTypeEnum.EXTENDED_OPERATION_SERVER, myRequestDetails, null, null, null, myRuleApplier, Set.of(), Pointcut.STORAGE_INITIATE_BULK_EXPORT); + + if (theExpectedVerdict != null) { + // Expect a decision + Assertions.assertNotNull(verdict, "Expected " + theExpectedVerdict + " but got abstain - " + theMessage); + assertEquals(theExpectedVerdict, verdict.getDecision(), "Expected " + theExpectedVerdict + " but got " + verdict.getDecision() + " - " + theMessage); + } else { + // Expect abstain + assertNull(verdict, "Expected abstain - " + theMessage); + } + } + + static Stream paramsInstanceLevel() { + IAuthorizationSearchParamMatcher.MatchResult match = IAuthorizationSearchParamMatcher.MatchResult.buildMatched(); + IAuthorizationSearchParamMatcher.MatchResult noMatch = IAuthorizationSearchParamMatcher.MatchResult.buildUnmatched(); + + // theAllowedResourceTypes, theBulkExportJobParams, theSearchParamMatcherMatchResults, theExpectedVerdict, theMessage + return Stream.of( + // One patient cases + Arguments.of(List.of(), new BulkExportParamsBuilder().exportOnePatient().build(), List.of(match), PolicyEnum.ALLOW, "Allow request for all types, permit all types"), + Arguments.of(List.of(), new BulkExportParamsBuilder().exportOnePatient().withResourceTypes("Patient", "Observation").build(), List.of(match), PolicyEnum.ALLOW, "Allow request for some types, permit all types"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().withResourceTypes("Patient","Observation").build(), List.of(match), PolicyEnum.ALLOW, "Allow request for exact set of allowable types"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().withResourceTypes("Patient").build(), List.of(match), PolicyEnum.ALLOW, "Allow request for subset of allowable types"), + Arguments.of(List.of("Patient"), new BulkExportParamsBuilder().exportOnePatient().withResourceTypes("Patient").build(), List.of(noMatch), null, "Abstain when requesting some resource types, but no resources match the permission query"), + Arguments.of(List.of(), new BulkExportParamsBuilder().exportOnePatient().build(), List.of(noMatch), null, "Abstain when requesting all resource types, but no resources match the permission query"), + // Below is the narrowing case. Narrowing should happen at the SecurityInterceptor layer. Here, we expect deny + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().build(), null, PolicyEnum.DENY, "Deny request for all types when allowing some types"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().withResourceTypes("Patient","Observation","Encounter").build(), null, PolicyEnum.DENY, "Deny request for superset of allowable types"), + Arguments.of(List.of(), new BulkExportParamsBuilder().exportOnePatient().withExportStyle(GROUP).build(), null, null, "Abstain when export style is not Group"), + + // Two patient cases + Arguments.of(List.of(), new BulkExportParamsBuilder().exportTwoPatients().build(), List.of(match, match), PolicyEnum.ALLOW, "Allow request for all types on 2 patients, permit all types"), + Arguments.of(List.of(), new BulkExportParamsBuilder().exportTwoPatients().withResourceTypes("Patient", "Observation").build(), List.of(match, match), PolicyEnum.ALLOW, "Allow request for some types on 2 patients, permit all types"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportTwoPatients().withResourceTypes("Patient","Observation").build(), List.of(match, match), PolicyEnum.ALLOW, "Allow request for exact set of allowable types on 2 patients"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportTwoPatients().withResourceTypes("Patient").build(), List.of(match, match), PolicyEnum.ALLOW, "Allow request on 2 patients for subset of allowable types"), + Arguments.of(List.of("Patient"), new BulkExportParamsBuilder().exportTwoPatients().withResourceTypes("Patient").build(), List.of(noMatch), null, "Abstain when requesting some resource types on 2 patients, but no resources match the permission query"), + Arguments.of(List.of("Patient"), new BulkExportParamsBuilder().exportTwoPatients().withResourceTypes("Patient").build(), List.of(match, noMatch), PolicyEnum.DENY, "Deny when requesting some resource types on 2 patients, but only one Patient match the permission query"), + + // Instance-level with _typeFilter + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().withTypeFilters("?identifier=foo|bar").withResourceTypes("Patient","Observation").build(), List.of(match), PolicyEnum.ALLOW, "Allow request with typeFilter for exact set of allowable types"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().withTypeFilters("?identifier=abc|def").withResourceTypes("Patient","Observation").build(), null, PolicyEnum.DENY, "Deny request with typeFilter when all don't match permissible filter"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportOnePatient().withTypeFilters("?identifier=foo|bar", "?name=Doe").withResourceTypes("Patient","Observation").build(), null, PolicyEnum.DENY, "Deny request with typeFilter when one doesn't match permissible filter"), + Arguments.of(List.of("Patient", "Observation"), new BulkExportParamsBuilder().exportTwoPatients().withTypeFilters("?identifier=foo|bar").withResourceTypes("Patient","Observation").build(), List.of(match, noMatch), PolicyEnum.DENY, "Deny request with typeFilter for exact set of allowable types, but matcher doesn't match") + ); + } + + @ParameterizedTest + @MethodSource("paramsTypeLevel") + void testPatientRule_typeLevelExport_withCompartmentMatchers(Collection theAllowedResourceTypes, + Collection thePermissionFilters, + BulkExportJobParameters theBulkExportJobParams, + PolicyEnum theExpectedVerdict, + String theMessage) { + RulePatientBulkExportByCompartmentMatcherImpl rule = new RulePatientBulkExportByCompartmentMatcherImpl("b"); + for (String filter : thePermissionFilters) { + rule.addAppliesToPatientExportOnPatient(filter); + } + rule.setResourceTypes(theAllowedResourceTypes); + rule.setMode(PolicyEnum.ALLOW); + + when(myRequestDetails.getUserData()).thenReturn(Map.of(AuthorizationInterceptor.REQUEST_ATTRIBUTE_BULK_DATA_EXPORT_OPTIONS, theBulkExportJobParams)); + + AuthorizationInterceptor.Verdict verdict = rule.applyRule(RestOperationTypeEnum.EXTENDED_OPERATION_SERVER, myRequestDetails, null, null, null, myRuleApplier, Set.of(), Pointcut.STORAGE_INITIATE_BULK_EXPORT); + + if (theExpectedVerdict != null) { + // Expect a decision + Assertions.assertNotNull(verdict, "Expected " + theExpectedVerdict + " but got abstain - " + theMessage); + assertEquals(theExpectedVerdict, verdict.getDecision(), "Expected " + theExpectedVerdict + " but got " + verdict.getDecision() + " - " + theMessage); + } else { + // Expect abstain + assertNull(verdict, "Expected abstain - " + theMessage); + } + } + + static Stream paramsTypeLevel() { + // theAllowedResourceTypes, thePermissionFilters, theBulkExportJobParams, theExpectedVerdict, theMessage + return Stream.of( + // Type-Level export with _typeFilter + Arguments.of(List.of(), List.of("?identifier=foo|bar"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar").build(), PolicyEnum.ALLOW, "Allow request when filters match"), + Arguments.of(List.of(), List.of("?identifier=foo|bar", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar").build(), PolicyEnum.ALLOW, "Allow request with subset of permitted filters"), + Arguments.of(List.of(), List.of("?identifier=foo|bar", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar", "Patient?name=Doe").build(), PolicyEnum.ALLOW, "Allow request when multiple filters match"), + Arguments.of(List.of("Patient"), List.of("?identifier=foo|bar", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar", "Patient?name=Doe").withResourceTypes("Patient").build(), PolicyEnum.ALLOW, "Allow request when multiple filters match including resource type"), + Arguments.of(List.of("Observation"), List.of("?identifier=foo|bar", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar", "Patient?name=Doe").withResourceTypes("Patient").build(), PolicyEnum.DENY, "Deny request when multiple filters match, but resource type doesn't"), + Arguments.of(List.of(), List.of("?identifier=foo|bar&active=true", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar&active=true", "Patient?name=Doe").build(), PolicyEnum.ALLOW, "Allow request when multiple filters match"), + Arguments.of(List.of(), List.of("?active=true&identifier=foo|bar", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar&active=true", "Patient?name=Doe").build(), PolicyEnum.ALLOW, "Allow request when multiple filters match"), + Arguments.of(List.of(), List.of("?identifier=foo|bar&active=true", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar", "Patient?name=Doe").build(), PolicyEnum.DENY, "Deny request when some tokenized filters match"), + Arguments.of(List.of(), List.of("?identifier=foo|bar", "?name=Doe"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar&active=true", "Patient?name=Doe").build(), PolicyEnum.DENY, "Deny request when filters don't match"), + Arguments.of(List.of(), List.of("?identifier=abc|def"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar").build(), PolicyEnum.DENY, "Deny request when filters do not match"), + Arguments.of(List.of(), List.of("?identifier=foo|bar"), new BulkExportParamsBuilder().withTypeFilters("Patient?identifier=foo|bar","Patient?name=Doe").build(), PolicyEnum.DENY, "Deny request when requesting more filters than permitted") + ); + } + + private static class BulkExportParamsBuilder { + + private final BulkExportJobParameters myBulkExportJobParameters; + + public BulkExportParamsBuilder() { + myBulkExportJobParameters = new BulkExportJobParameters(); + myBulkExportJobParameters.setExportStyle(PATIENT); + } + + public BulkExportParamsBuilder withExportStyle(BulkExportJobParameters.ExportStyle theStyle) { + myBulkExportJobParameters.setExportStyle(theStyle); + return this; + } + + public BulkExportParamsBuilder withResourceTypes(String... theResourceTypes) { + myBulkExportJobParameters.setResourceTypes(List.of(theResourceTypes)); + return this; + } + + public BulkExportParamsBuilder exportOnePatient() { + myBulkExportJobParameters.setPatientIds(List.of("Patient/1")); + return this; + } + + public BulkExportParamsBuilder exportTwoPatients() { + myBulkExportJobParameters.setPatientIds(List.of("Patient/1", "Patient/2")); + return this; + } + + public BulkExportParamsBuilder withTypeFilters(String... theFilters) { + myBulkExportJobParameters.setFilters(List.of(theFilters)); + return this; + } + + public BulkExportJobParameters build() { + return myBulkExportJobParameters; + } + } + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/UrlUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/UrlUtilTest.java index ddae00647905..cf9dc9b3c49a 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/UrlUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/UrlUtilTest.java @@ -103,6 +103,8 @@ public void testParseUrl() { assertEquals("a=b", UrlUtil.parseUrl("/ConceptMap?a=b").getParams()); assertEquals("a=b", UrlUtil.parseUrl("/ConceptMap/ussgfht-loincde?a=b").getParams()); + assertEquals("a=b", UrlUtil.parseUrl("?a=b").getParams()); + } @Test