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 c83230f0b8d8..111a77a7c580 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-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/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..84e2ff18856b 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; } - ; + + /** + * The auth resource resolve is a service that allows you to query the DB for a resource, given a resource ID + * WARNING: This is slow, and should have limited use in authorization. + * + * 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..2eb2c9e86745 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 RuleBuilderGroupBulkExportWithFilter(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 RuleBuilderPatientBulkExportWithFilter(myRulePatientBulkExportByCompartmentMatcher); + } + private class RuleBuilderBulkExportWithTarget extends RuleBuilderFinished implements IAuthRuleBuilderRuleBulkExportWithTarget { private final RuleBulkExportImpl myRule; @@ -1000,6 +1047,38 @@ public IAuthRuleBuilderRuleBulkExportWithTarget withResourceTypes(Collection theResourceTypes) { + myRule.setResourceTypes(theResourceTypes); + return this; + } + } + + private class RuleBuilderPatientBulkExportWithFilter extends RuleBuilderFinished + implements IAuthRuleBuilderRuleBulkExportWithTarget { + private final RulePatientBulkExportByCompartmentMatcherImpl myRule; + + private RuleBuilderPatientBulkExportWithFilter(RulePatientBulkExportByCompartmentMatcherImpl theRule) { + super(theRule); + myRule = theRule; + } + + @Override + 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..450366b27a60 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RuleGroupBulkExportByCompartmentMatcherImpl.java @@ -0,0 +1,129 @@ +/*- + * #%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 com.google.common.annotations.VisibleForTesting; +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; +import static org.apache.commons.collections4.CollectionUtils.isEmpty; +import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; + +public class RuleGroupBulkExportByCompartmentMatcherImpl extends BaseRule { + private static final BulkExportJobParameters.ExportStyle OUR_EXPORT_STYLE = + BulkExportJobParameters.ExportStyle.GROUP; + private String myGroupMatcherFilter; + private Collection myResourceTypes; + + RuleGroupBulkExportByCompartmentMatcherImpl(String theRuleName) { + super(theRuleName); + } + + @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() != OUR_EXPORT_STYLE) { + // If the requested export style is not for a GROUP, 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); + } + } + + 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 setResourceTypes(Collection theResourceTypes) { + myResourceTypes = theResourceTypes; + } + + public void setAppliesToGroupExportOnGroup(String theGroupMatcherFilter) { + String sanitizedFilter = sanitizeQueryFilter(theGroupMatcherFilter); + myGroupMatcherFilter = sanitizedFilter; + addTester(new FhirQueryRuleTester(sanitizedFilter)); + } + + public String getGroupMatcherFilter() { + return myGroupMatcherFilter; + } + + /** + * Remove the resource type and "?" prefix, if present + * since resource type is implied for the rule based on the permission (Patient in this case) + */ + private static String sanitizeQueryFilter(String theFilter) { + if (theFilter.contains("?")) { + return theFilter.substring(theFilter.indexOf("?") + 1); + } + return theFilter; + } + + @VisibleForTesting + Collection getResourceTypes() { + return myResourceTypes; + } +} 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..19481d55c620 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/auth/RulePatientBulkExportByCompartmentMatcherImpl.java @@ -0,0 +1,218 @@ +/*- + * #%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.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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; +import static org.apache.commons.collections4.CollectionUtils.isEmpty; +import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; + +public class RulePatientBulkExportByCompartmentMatcherImpl extends BaseRule { + private static final BulkExportJobParameters.ExportStyle OUR_EXPORT_STYLE = + BulkExportJobParameters.ExportStyle.PATIENT; + private List myPatientMatcherFilter; + private List> myTokenizedPatientMatcherFilter; + private Collection myResourceTypes; + + RulePatientBulkExportByCompartmentMatcherImpl(String theRuleName) { + super(theRuleName); + } + + @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() != OUR_EXPORT_STYLE) { + // If the requested export style is not for a PATIENT, 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(DENY, this); + } + if (!myResourceTypes.containsAll(inboundBulkExportRequestOptions.getResourceTypes())) { + // The requested resource types is not a subset of the permitted resource types + return new AuthorizationInterceptor.Verdict(DENY, this); + } + } + + 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 thePatientResources = + theRuleApplier.getAuthResourceResolver().resolveResourcesByIds(patientIdOptions, "Patient"); + + // 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. + Map counts = new HashMap<>(); + counts.put(true, 0); + counts.put(false, 0); + + for (IBaseResource patient : thePatientResources) { + boolean applies = atLeastOneTesterMatches(theOperation, theRequestDetails, patient, theRuleApplier); + + counts.put(applies, counts.get(applies) + 1); + + if (counts.get(applies) > 0 && counts.get(!applies) > 0) { + // 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 to have a mixture due to the early-return in the for loop + return counts.get(true) > 0 ? 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 = sanitizeQueryFilter(filter); + + Set tokenizedQuery = Set.of(query.split("&")); + + if (!myTokenizedPatientMatcherFilter.contains(tokenizedQuery)) { + return false; + } + } + return true; + } + + /** + * Remove the resource type and "?" prefix, if present + * since resource type is implied for the rule based on the permission (Patient in this case) + */ + private static String sanitizeQueryFilter(String theFilter) { + if (theFilter.contains("?")) { + return theFilter.substring(theFilter.indexOf("?") + 1); + } + return theFilter; + } + + public void setResourceTypes(Collection theResourceTypes) { + myResourceTypes = theResourceTypes; + } + + /** + * @param thePatientMatcherFilter the matcher filter for the permitted Patient + */ + public void addAppliesToPatientExportOnPatient(String thePatientMatcherFilter) { + + if (myPatientMatcherFilter == null) { + myPatientMatcherFilter = new ArrayList<>(); + } + + String sanitizedFilter = sanitizeQueryFilter(thePatientMatcherFilter); + myPatientMatcherFilter.add(sanitizedFilter); + addTester(new FhirQueryRuleTester(sanitizedFilter)); + + if (myTokenizedPatientMatcherFilter == null) { + myTokenizedPatientMatcherFilter = new ArrayList<>(); + } + + myTokenizedPatientMatcherFilter.add(Set.of(thePatientMatcherFilter.split("&"))); + } + + public List getPatientMatcherFilters() { + return myPatientMatcherFilter; + } + + @VisibleForTesting + Collection getResourceTypes() { + return myResourceTypes; + } + + @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..c07dd97d5661 --- /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..291a175b4fef --- /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; + } + } + +}