Introduce memory-matcher based rules (#3677)

Extend rules with filters.

New rule extension, and consent service for post-query filtering.

Co-authored-by: josie <josie.vandewetering@smilecdr.com>
Co-authored-by: Jason Roberts <jason.roberts@smilecdr.com>
Co-authored-by: Ken Stevens <khstevens@gmail.com>
This commit is contained in:
michaelabuckley 2022-06-29 14:23:12 -04:00 committed by GitHub
parent b54b7b133e
commit 9aac4f9ba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 896 additions and 12 deletions

View File

@ -38,18 +38,21 @@ import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.interceptor.auth.*;
import ca.uhn.fhir.rest.server.interceptor.consent.ConsentInterceptor;
import ca.uhn.fhir.rest.server.interceptor.consent.RuleFilteringConsentService;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import com.google.common.collect.Lists;
import org.hl7.fhir.dstu3.model.IdType;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Patient;
import java.util.Collections;
import java.util.List;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
@SuppressWarnings("unused")
/**
* Examples integrated into our documentation.
*/
public class AuthorizationInterceptors {
public class PatientResourceProvider implements IResourceProvider
@ -123,8 +126,20 @@ public class AuthorizationInterceptors {
}
//END SNIPPET: patientAndAdmin
public void ruleFiltering() {
RestfulServer restfulServer = new RestfulServer();
AuthorizationInterceptor theAuthorizationInterceptor = new AuthorizationInterceptor();
//START SNIPPET: conditionalUpdate
//START SNIPPET: ruleFiltering
ConsentInterceptor consentInterceptor = new ConsentInterceptor();
consentInterceptor.registerConsentService(new RuleFilteringConsentService(theAuthorizationInterceptor));
restfulServer.registerInterceptor(consentInterceptor);
//END SNIPPET: ruleFiltering
}
//START SNIPPET: conditionalUpdate
@Update()
public MethodOutcome update(
@IdParam IdType theId,
@ -298,6 +313,7 @@ public class AuthorizationInterceptors {
}
//START SNIPPET: narrowingByCode
public class MyCodeSearchNarrowingInterceptor extends SearchNarrowingInterceptor {

View File

@ -0,0 +1,5 @@
---
type: add
issue: 3674
title: "The rule system has been extended to support optional fhir-expressions that narrow the application of a rule.
A matching RuleFilteringConsentService implements post-query support for this filtering using an in-memory matcher."

View File

@ -19,6 +19,14 @@ The AuthorizationInterceptor is used by subclassing it and then registering your
{{snippet:classpath:/ca/uhn/hapi/fhir/docs/AuthorizationInterceptors.java|patientAndAdmin}}
```
The core rules support restricting access by resource type, resource instance, and compartment.
The rules also support query filters expressed by FHIR queries - e.g. `code:above=http://loinc.org|55399-0` to restrict Observations to just the diabetes panel.
To use query filters, you must activate the [RuleFilteringConsentService.](/apidocs/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/RuleFilteringConsentService.java)
```java
{{snippet:classpath:/ca/uhn/hapi/fhir/docs/AuthorizationInterceptors.java|ruleFiltering}}
```
## Using AuthorizationInterceptor in a REST Server
The AuthorizationInterceptor works by examining the client request in order to determine whether "write" operations are legal, and looks at the response from the server in order to determine whether "read" operations are legal.

View File

@ -152,7 +152,7 @@ public class MatchUrlService {
} else {
RuntimeSearchParam paramDef = mySearchParamRegistry.getActiveSearchParam(theResourceDefinition.getName(), nextParamName);
if (paramDef == null) {
throw new InvalidRequestException(Msg.code(488) + "Failed to parse match URL[" + theMatchUrl + "] - Resource type " + theResourceDefinition.getName() + " does not have a parameter with name: " + nextParamName);
throw throwUnrecognizedParamException(theMatchUrl, theResourceDefinition, nextParamName);
}
IQueryParameterAnd<?> param = JpaParamUtil.parseQueryParams(mySearchParamRegistry, myFhirContext, paramDef, nextParamName, paramList);
@ -162,6 +162,29 @@ public class MatchUrlService {
return paramMap;
}
public static class UnrecognizedSearchParameterException extends InvalidRequestException {
private final String myResourceName;
private final String myParamName;
UnrecognizedSearchParameterException(String theMessage, String theResourceName, String theParamName) {
super(theMessage);
myResourceName = theResourceName;
myParamName = theParamName;
}
public String getResourceName() {
return myResourceName;
}
public String getParamName() {
return myParamName;
}
}
private InvalidRequestException throwUnrecognizedParamException(String theMatchUrl, RuntimeResourceDefinition theResourceDefinition, String nextParamName) {
return new UnrecognizedSearchParameterException(Msg.code(488) + "Failed to parse match URL[" + theMatchUrl + "] - Resource type " + theResourceDefinition.getName() + " does not have a parameter with name: " + nextParamName, theResourceDefinition.getName(), nextParamName);
}
private IQueryParameterAnd<?> newInstanceAnd(String theParamType) {
Class<? extends IQueryParameterAnd<?>> clazz = ResourceMetaParams.RESOURCE_META_AND_PARAMS.get(theParamType);
return ReflectionUtil.newInstance(clazz);

View File

@ -0,0 +1,33 @@
package ca.uhn.fhir.jpa.searchparam.matcher;
import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
import ca.uhn.fhir.rest.server.interceptor.auth.IAuthorizationSearchParamMatcher;
import org.hl7.fhir.instance.model.api.IBaseResource;
public class AuthorizationSearchParamMatcher implements IAuthorizationSearchParamMatcher {
private final SearchParamMatcher mySearchParamMatcher;
public AuthorizationSearchParamMatcher(SearchParamMatcher mySearchParamMatcher) {
this.mySearchParamMatcher = mySearchParamMatcher;
}
@Override
public MatchResult match(String theCriteria, IBaseResource theResource) {
try {
InMemoryMatchResult inMemoryMatchResult = mySearchParamMatcher.match(theCriteria, theResource, null);
if (!inMemoryMatchResult.supported()) {
return new MatchResult(Match.UNSUPPORTED, inMemoryMatchResult.getUnsupportedReason());
}
if (inMemoryMatchResult.matched()) {
return new MatchResult(Match.MATCH, null);
} else {
return new MatchResult(Match.NO_MATCH, null);
}
} catch (MatchUrlService.UnrecognizedSearchParameterException e) {
// wipmb revisit this design
// The matcher treats a bad expression as InvalidRequestException because it assumes it is during SearchParameter storage.
// Instead, we adapt this to UNSUPPORTED during authorization. We may be applying to all types, and this filter won't match.
return new MatchResult(Match.UNSUPPORTED, e.getMessage());
}
}
}

View File

@ -129,6 +129,10 @@ public class InMemoryResourceMatcher {
} catch (UnsupportedOperationException e) {
return InMemoryMatchResult.unsupportedFromReason(InMemoryMatchResult.PARSE_FAIL);
}
// wipjv consider merging InMemoryMatchResult with IAuthorizationSearchParamMatcher.Match match type
// } catch (MatchUrlService.UnrecognizedSearchParameterException e) {
// return InMemoryMatchResult.unsupportedFromReason(InMemoryMatchResult.PARAM);
searchParameterMap.clean();
return match(searchParameterMap, theResource, resourceDefinition, theSearchParams);
}

View File

@ -192,6 +192,15 @@ public class InMemoryResourceMatcherR5Test {
assertFalse(result.matched());
verify(myValidationSupport).validateCode(any(), any(), eq(OBSERVATION_CODE_SYSTEM), eq(OBSERVATION_CODE), isNull(), eq(OBSERVATION_CODE_VALUE_SET_URI));
}
@Test
public void testUnrecognizedParam() {
try {
InMemoryMatchResult result = myInMemoryResourceMatcher.match("foo=bar", myObservation, mySearchParams);
} catch (MatchUrlService.UnrecognizedSearchParameterException e) {
// expected
}
}
@Test

View File

@ -22,7 +22,6 @@ package ca.uhn.fhir.jpa.subscription.match.matcher.matching;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
import ca.uhn.fhir.jpa.searchparam.MatchUrlService;

View File

@ -23,7 +23,6 @@ package ca.uhn.fhir.jpa.subscription.match.matcher.matching;
import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult;
import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryResourceMatcher;
import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionCriteriaParser;
import ca.uhn.fhir.rest.api.Constants;
import org.springframework.beans.factory.annotation.Autowired;
public class SubscriptionStrategyEvaluator {

View File

@ -37,7 +37,7 @@ public class SubscriptionStrategyEvaluatorTest extends BaseSubscriptionDstu3Test
assertDatabase("Observation?context.type=IHD&code=17861-6");
try {
assertInMemory("Observation?codeee=SNOMED-CT|123&_format=xml");
mySubscriptionStrategyEvaluator.determineStrategy("Observation?codeee=SNOMED-CT|123&_format=xml");
fail();
} catch (InvalidRequestException e) {
assertThat(e.getMessage(), containsString("Resource type Observation does not have a parameter with name: codeee"));

View File

@ -0,0 +1,284 @@
package ca.uhn.fhir.jpa.auth;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.jpa.partition.SystemRequestDetails;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor;
import ca.uhn.fhir.rest.server.interceptor.auth.FhirQueryRuleImpl;
import ca.uhn.fhir.rest.server.interceptor.auth.IAuthorizationSearchParamMatcher;
import ca.uhn.fhir.rest.server.interceptor.auth.PolicyEnum;
import ca.uhn.fhir.rest.server.interceptor.auth.RuleBuilder;
import ca.uhn.fhir.test.utilities.ITestDataBuilder;
import ca.uhn.test.util.LogbackCaptureTestExtension;
import ch.qos.logback.classic.Level;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import java.util.HashSet;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.Mockito.when;
// wipjv where should this test live? -
// wipjv can we mock the resource? We just use it for stubbing here. If so, move this back to hapi-fhir-server ca.uhn.fhir.rest.server.interceptor.auth
@MockitoSettings
class FhirQueryRuleImplTest implements ITestDataBuilder {
final private TestRuleApplier myMockRuleApplier = new TestRuleApplier() {
@Override
public @Nullable IAuthorizationSearchParamMatcher getSearchParamMatcher() {
return myMatcher;
}
};
@RegisterExtension
LogbackCaptureTestExtension myLogCapture = new LogbackCaptureTestExtension(myMockRuleApplier.getTroubleshootingLog().getName());
private FhirQueryRuleImpl myRule;
private IBaseResource myResource;
private IBaseResource myResource2;
//@Mock
private final SystemRequestDetails mySrd = new SystemRequestDetails();
private final FhirContext myFhirContext = FhirContext.forR4Cached();
@Mock
private IAuthorizationSearchParamMatcher myMatcher;
@BeforeEach
public void setUp() {
mySrd.setFhirContext(myFhirContext);
}
// // our IRuleApplierStubs
// @Override
// public Logger getTroubleshootingLog() {
// return ourTargetLog;
// }
//
// public IAuthorizationSearchParamMatcher getSearchParamMatcher() {
// return myMatcher;
// }
//
// @Override
// public AuthorizationInterceptor.Verdict applyRulesAndReturnDecision(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, IBaseResource theOutputResource, Pointcut thePointcut) {
// return null;
// }
@Nested
public class MatchingLogic {
@Test
public void simpleStringSearch_whenMatchResource_allow() {
// given
withPatientWithNameAndId();
RuleBuilder b = new RuleBuilder();
myRule = (FhirQueryRuleImpl) b.allow()
.read()
.resourcesOfType("Patient")
.withFilter( "family=Smith")
.andThen().build().get(0);
when(myMatcher.match(ArgumentMatchers.eq("Patient?family=Smith"), ArgumentMatchers.same(myResource)))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeMatched());
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource);
// then
assertThat(verdict, notNullValue());
assertThat(verdict.getDecision(), equalTo(PolicyEnum.ALLOW));
}
@Test
public void simpleStringSearch_noMatch_noVerdict() {
// given
withPatientWithNameAndId();
myRule = (FhirQueryRuleImpl) new RuleBuilder().allow().read().resourcesOfType("Patient")
.inCompartmentWithFilter("patient", myResource.getIdElement().withResourceType("Patient"), "family=smi")
.andThen().build().get(0);
when(myMatcher.match(ArgumentMatchers.eq("Patient?family=smi"), ArgumentMatchers.same(myResource)))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeUnmatched());
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource);
// then
assertThat(verdict, nullValue());
}
@Test
public void observation_notInCompartmentMatchFilter_noVerdict() {
// given
withPatientWithNameAndId();
// create patient for observation to point to so that the observation isn't in our main patient compartment
IBaseResource patient = buildResource("Patient", withFamily("Jones"), withId("bad-id"));
withObservationWithSubjectAndCode(patient.getIdElement());
myRule = (FhirQueryRuleImpl) new RuleBuilder().allow().read().resourcesOfType("Observation")
.inCompartmentWithFilter("patient", myResource.getIdElement().withResourceType("Patient"), "code=28521000087105")
.andThen().build().get(0);
when(myMatcher.match("Observation?code=28521000087105", myResource2))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeUnmatched());
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource2);
// then
assertThat(verdict, nullValue());
}
@Test
public void observation_noMatchFilter_noVerdict() {
// given
withPatientWithNameAndId();
withObservationWithSubjectAndCode(myResource.getIdElement());
myRule = (FhirQueryRuleImpl) new RuleBuilder().allow().read().resourcesOfType("Observation")
.withFilter("code=12")
.andThen().build().get(0);
when(myMatcher.match("Observation?code=12", myResource2))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeUnmatched());
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource2);
// then
assertThat(verdict, nullValue());
}
@Test
public void observation_denyWithFilter_deny() {
// given
withPatientWithNameAndId();
withObservationWithSubjectAndCode(myResource.getIdElement());
myRule = (FhirQueryRuleImpl) new RuleBuilder().deny().read().resourcesOfType("Observation")
.withFilter("code=28521000087105")
.andThen().build().get(0);
when(myMatcher.match("Observation?code=28521000087105", myResource2))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeMatched());
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource2);
// then
assertThat(verdict, notNullValue());
assertThat(verdict.getDecision(), equalTo(PolicyEnum.DENY));
}
}
@Nested
public class MisconfigurationChecks {
// wipjv check for unsupported params during CdrAuthInterceptor scopes->perms translation.
/**
* in case an unsupported perm snuck through the front door.
* Each scope provides positive perm, so unsupported means we can't vote yes. Abstain.
*/
@Test
public void givenAllowRule_whenUnsupportedQuery_noVerdict() {
withPatientWithNameAndId();
myRule = (FhirQueryRuleImpl) new RuleBuilder().allow().read().resourcesOfType("Patient")
.inCompartmentWithFilter("patient", myResource.getIdElement().withResourceType("Patient"), "family=smi").andThen().build().get(0);
when(myMatcher.match("Patient?family=smi", myResource))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeUnsupported("I'm broken unsupported chain XXX"));
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource);
// then
assertThat(verdict, nullValue());
assertThat(myLogCapture.getLogEvents(),
hasItem(myLogCapture.eventWithLevelAndMessageContains(Level.WARN, "unsupported chain XXX")));
}
@Test
public void givenDenyRule_whenUnsupportedQuery_reject() {
withPatientWithNameAndId();
myRule = (FhirQueryRuleImpl) new RuleBuilder().deny().read().resourcesOfType("Patient")
.inCompartmentWithFilter("patient", myResource.getIdElement().withResourceType("Patient"), "family=smi").andThen().build().get(0);
when(myMatcher.match("Patient?family=smi", myResource))
.thenReturn(IAuthorizationSearchParamMatcher.MatchResult.makeUnsupported("I'm broken unsupported chain XXX"));
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource);
// then
assertThat(verdict.getDecision(), equalTo(PolicyEnum.DENY));
assertThat(myLogCapture.getLogEvents(),
hasItem(myLogCapture.eventWithLevelAndMessageContains(Level.WARN, "unsupported chain XXX")));
}
/**
* for backwards compatibility, if the IRuleApplier doesn't provide a matcher service,
* log a warning, and return no verdict.
*/
@Test
public void noMatcherService_unsupportedPerm_noVerdict() {
withPatientWithNameAndId();
myMatcher = null;
myRule = (FhirQueryRuleImpl) new RuleBuilder().allow().read().resourcesOfType("Patient")
.inCompartmentWithFilter("patient", myResource.getIdElement().withResourceType("Observation"), "code:in=foo").andThen().build().get(0);
// when
AuthorizationInterceptor.Verdict verdict = applyRuleToResource(myResource);
// then
assertThat(verdict, nullValue());
assertThat(myLogCapture.getLogEvents(),
hasItem(myLogCapture.eventWithLevelAndMessageContains(Level.WARN, "No matcher provided")));
}
}
// wipjv how to test the difference between patient/*.rs?code=foo and patient/Observation.rs?code=foo?
// We need the builder to set AppliesTypeEnum, and the use that to build the matcher expression.
private AuthorizationInterceptor.Verdict applyRuleToResource(IBaseResource theResource) {
return myRule.applyRule(RestOperationTypeEnum.SEARCH_TYPE, mySrd, null, null, theResource, myMockRuleApplier, new HashSet<>(), Pointcut.STORAGE_PRESHOW_RESOURCES);
}
private void withPatientWithNameAndId() {
myResource = buildResource("Patient", withFamily("Smith"), withId("some-id"));
}
// Use in sequence with above
private void withObservationWithSubjectAndCode(IIdType theIdElement) {
String snomedUriString = "http://snomed.info/sct";
String insulin2hCode = "28521000087105";
myResource2 = buildResource("Observation", withObservationCode(snomedUriString, insulin2hCode), withSubject(theIdElement));
}
@Override
public IIdType doCreateResource(IBaseResource theResource) {
return null;
}
@Override
public IIdType doUpdateResource(IBaseResource theResource) {
return null;
}
@Override
public FhirContext getFhirContext() {
return FhirContext.forR4Cached();
}
}

View File

@ -0,0 +1,45 @@
package ca.uhn.fhir.jpa.auth;
import ca.uhn.fhir.context.support.IValidationSupport;
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.server.interceptor.auth.AuthorizationInterceptor;
import ca.uhn.fhir.rest.server.interceptor.auth.IAuthorizationSearchParamMatcher;
import ca.uhn.fhir.rest.server.interceptor.auth.IRuleApplier;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Empty implementation to base a stub.
*/
public class TestRuleApplier implements IRuleApplier {
private static final Logger ourLog = LoggerFactory.getLogger(TestRuleApplier.class);
@NotNull
@Override
public Logger getTroubleshootingLog() {
return ourLog;
}
@Override
public AuthorizationInterceptor.Verdict applyRulesAndReturnDecision(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, IBaseResource theOutputResource, Pointcut thePointcut) {
return null;
}
@Nullable
@Override
public IValidationSupport getValidationSupport() {
return null;
}
@Nullable
@Override
public IAuthorizationSearchParamMatcher getSearchParamMatcher() {
return IRuleApplier.super.getSearchParamMatcher();
}
}

View File

@ -0,0 +1,5 @@
/**
* Light integration tests of the filter rules.
* Down here in jpaserver to test with real resources.
*/
package ca.uhn.fhir.jpa.auth;

View File

@ -32,6 +32,7 @@ public abstract class BaseJpaR4SystemTest extends BaseJpaR4Test {
}
when(mySrd.getServer()).thenReturn(myServer);
when(mySrd.getFhirContext()).thenReturn(myFhirContext);
HttpServletRequest servletRequest = mock(HttpServletRequest.class);
when(mySrd.getServletRequest()).thenReturn(servletRequest);
when(mySrd.getFhirServerBase()).thenReturn("http://example.com/base");

View File

@ -1479,7 +1479,6 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
request.addEntry().setResource(o).getRequest().setMethod(HTTPVerb.POST);
when(mySrd.getRestOperationType()).thenReturn(RestOperationTypeEnum.TRANSACTION);
when(mySrd.getFhirContext().getResourceType(any(Observation.class))).thenReturn("Observation");
myInterceptorRegistry.registerInterceptor(new AuthorizationInterceptor(PolicyEnum.ALLOW) {
@Override
@ -1534,7 +1533,6 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
mySystemDao.transaction(mySrd, request1);
when(mySrd.getRestOperationType()).thenReturn(RestOperationTypeEnum.TRANSACTION);
when(mySrd.getFhirContext().getResourceType(any(Observation.class))).thenReturn("Observation");
myInterceptorRegistry.registerInterceptor(new AuthorizationInterceptor(PolicyEnum.ALLOW) {
@Override

View File

@ -7,6 +7,8 @@ import ca.uhn.fhir.jpa.bulk.export.provider.BulkDataExportProvider;
import ca.uhn.fhir.jpa.dao.r4.FhirResourceDaoR4TerminologyTest;
import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor;
import ca.uhn.fhir.jpa.model.util.JpaConstants;
import ca.uhn.fhir.jpa.searchparam.matcher.AuthorizationSearchParamMatcher;
import ca.uhn.fhir.jpa.searchparam.matcher.SearchParamMatcher;
import ca.uhn.fhir.model.primitive.IdDt;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
@ -68,6 +70,7 @@ import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@ -80,6 +83,9 @@ public class AuthorizationInterceptorJpaR4Test extends BaseResourceProviderR4Tes
@Autowired
private IBulkDataExportSvc myBulkDataExportSvc;
@Autowired
private SearchParamMatcher mySearchParamMatcher;
@BeforeEach
@Override
public void before() throws Exception {
@ -1749,4 +1755,51 @@ public class AuthorizationInterceptorJpaR4Test extends BaseResourceProviderR4Tes
.resourceConditionalByUrl("Patient?name=Siobhan&_expunge=true")
.execute();
}
@Test
public void testSmartFilterSearchAllowed() {
createObservation(withId("allowed"), withObservationCode(FhirResourceDaoR4TerminologyTest.URL_MY_CODE_SYSTEM, "A"));
createObservation(withId("allowed2"), withObservationCode(FhirResourceDaoR4TerminologyTest.URL_MY_CODE_SYSTEM, "foo"));
AuthorizationInterceptor interceptor = new AuthorizationInterceptor(PolicyEnum.DENY) {
@Override
public List<IAuthRule> buildRuleList(RequestDetails theRequestDetails) {
return new RuleBuilder()
.allow("filter rule").read().allResources().withFilter("code=" + FhirResourceDaoR4TerminologyTest.URL_MY_CODE_SYSTEM + "|").andThen()
.build();
}
};
interceptor.setAuthorizationSearchParamMatcher(new AuthorizationSearchParamMatcher(mySearchParamMatcher));
ourRestServer.registerInterceptor(interceptor);
// search runs without 403.
Bundle bundle = myClient.search().byUrl("/Observation?code=foo").returnBundle(Bundle.class).execute();
assertThat(bundle.getEntry(), hasSize(1));
}
@Test
public void testSmartFilterSearch_badQuery_abstain() {
createObservation(withId("obs1"), withObservationCode(FhirResourceDaoR4TerminologyTest.URL_MY_CODE_SYSTEM, "A"));
createObservation(withId("obs2"), withObservationCode(FhirResourceDaoR4TerminologyTest.URL_MY_CODE_SYSTEM, "foo"));
AuthorizationInterceptor interceptor = new AuthorizationInterceptor(PolicyEnum.DENY) {
@Override
public List<IAuthRule> buildRuleList(RequestDetails theRequestDetails) {
return new RuleBuilder()
.allow("filter rule").read().allResources().withFilter("unknown_code=foo").andThen()
.build();
}
};
interceptor.setAuthorizationSearchParamMatcher(new AuthorizationSearchParamMatcher(mySearchParamMatcher));
ourRestServer.registerInterceptor(interceptor);
// search should fail since the allow rule can't be evaluated with an unknown SP
try {
myClient.search().byUrl("/Observation").returnBundle(Bundle.class).execute();
fail("expect 403 error");
} catch (ForbiddenOperationException e) {
// expected
}
}
}

View File

@ -81,6 +81,8 @@ public class AuthorizationInterceptor implements IRuleApplier {
private PolicyEnum myDefaultPolicy = PolicyEnum.DENY;
private Set<AuthorizationFlagsEnum> myFlags = Collections.emptySet();
private IValidationSupport myValidationSupport;
private IAuthorizationSearchParamMatcher myAuthorizationSearchParamMatcher;
private Logger myTroubleshootingLog;
/**
@ -177,6 +179,20 @@ public class AuthorizationInterceptor implements IRuleApplier {
return this;
}
/**
* Sets a search parameter matcher for use in handling SMART v2 filter scopes
*
* @param theAuthorizationSearchParamMatcher The search parameter matcher. Defaults to null.
*/
public void setAuthorizationSearchParamMatcher(@Nullable IAuthorizationSearchParamMatcher theAuthorizationSearchParamMatcher) {
this.myAuthorizationSearchParamMatcher = theAuthorizationSearchParamMatcher;
}
@Nullable
public IAuthorizationSearchParamMatcher getSearchParamMatcher() {
return myAuthorizationSearchParamMatcher;
}
/**
* Subclasses should override this method to supply the set of rules to be applied to
* this individual request.

View File

@ -0,0 +1,90 @@
package ca.uhn.fhir.rest.server.interceptor.auth;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.util.Set;
/**
* Extension to rules that also requires matching a query filter, e.g. code=foo
*/
public class FhirQueryRuleImpl extends RuleImplOp {
private static final Logger ourLog = LoggerFactory.getLogger(FhirQueryRuleImpl.class);
private String myFilter;
/**
* Constructor
*/
public FhirQueryRuleImpl(String theRuleName) {
super(theRuleName);
}
public void setFilter(String theFilter) {
myFilter = theFilter;
}
public String getFilter() {
return myFilter;
}
/**
* Our override that first checks our filter against the resource if present.
*/
@Override
protected AuthorizationInterceptor.Verdict applyRuleLogic(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, IBaseResource theOutputResource, Set<AuthorizationFlagsEnum> theFlags, FhirContext theFhirContext, RuleTarget theRuleTarget, IRuleApplier theRuleApplier) {
ourLog.trace("applyRuleLogic {} {}", theOperation, theRuleTarget);
// Note - theOutputResource == null means we're in some other pointcut and don't have a result yet.
if (theOutputResource == null) {
return super.applyRuleLogic(theOperation, theRequestDetails, theInputResource, theInputResourceId, theOutputResource, theFlags, theFhirContext, theRuleTarget, theRuleApplier);
}
// look for a matcher
IAuthorizationSearchParamMatcher matcher = theRuleApplier.getSearchParamMatcher();
if (matcher == null) {
theRuleApplier.getTroubleshootingLog().warn("No matcher provided. Can't apply filter permission.");
if ( PolicyEnum.DENY.equals(getMode())) {
return new AuthorizationInterceptor.Verdict(PolicyEnum.DENY, this);
}
return null;
}
// wipjv check in vs out resource - write will need to check write.
// wipjv what about the id case - does that path doesn't call applyRuleLogic()
IAuthorizationSearchParamMatcher.MatchResult mr = matcher.match(theOutputResource.fhirType() + "?" + myFilter, theOutputResource);
AuthorizationInterceptor.Verdict result;
switch (mr.getMatch()) {
case MATCH:
result = super.applyRuleLogic(theOperation, theRequestDetails, theInputResource, theInputResourceId, theOutputResource, theFlags, theFhirContext, theRuleTarget, theRuleApplier);
break;
case UNSUPPORTED:
theRuleApplier.getTroubleshootingLog().warn("Unsupported matcher expression {}: {}. Abstaining.", myFilter, mr.getUnsupportedReason());
if ( PolicyEnum.DENY.equals(getMode())) {
result = new AuthorizationInterceptor.Verdict(PolicyEnum.DENY, this);
} else {
result = null;
}
break;
case NO_MATCH:
default:
result = null;
break;
}
return result;
}
@Nonnull
@Override
protected ToStringBuilder toStringBuilder() {
return super.toStringBuilder()
.append("filter", myFilter);
}
}

View File

@ -21,7 +21,6 @@ package ca.uhn.fhir.rest.server.interceptor.auth;
*/
import java.util.Collection;
import java.util.List;
import org.hl7.fhir.instance.model.api.IIdType;
@ -134,4 +133,8 @@ public interface IAuthRuleBuilderRuleOpClassifier {
* @since 6.0.0
*/
IAuthRuleFinished withCodeNotInValueSet(@Nonnull String theSearchParameterName, @Nonnull String theValueSetUrl);
IAuthRuleFinished inCompartmentWithFilter(String theCompartment, IIdType theIdElement, String theFilter);
IAuthRuleFinished withFilter(String theFilter);
}

View File

@ -0,0 +1,49 @@
package ca.uhn.fhir.rest.server.interceptor.auth;
import org.hl7.fhir.instance.model.api.IBaseResource;
import javax.annotation.Nonnull;
/**
* wipjv Ken do we like this name, or this package?
*/
public interface IAuthorizationSearchParamMatcher {
MatchResult match(String theCriteria, IBaseResource theResource);
enum Match {
MATCH,
NO_MATCH,
UNSUPPORTED
}
public static class MatchResult {
private final Match myMatch;
private final String myUnsupportedReason;
public static MatchResult makeMatched() {
return new MatchResult(Match.MATCH, null);
}
public static MatchResult makeUnmatched() {
return new MatchResult(Match.NO_MATCH, null);
}
public static MatchResult makeUnsupported(@Nonnull String theReason) {
return new MatchResult(Match.UNSUPPORTED, theReason);
}
public MatchResult(Match myMatch, String myUnsupportedReason) {
this.myMatch = myMatch;
this.myUnsupportedReason = myUnsupportedReason;
}
public Match getMatch() {
return myMatch;
}
public String getUnsupportedReason() {
return myUnsupportedReason;
}
}
}

View File

@ -42,4 +42,9 @@ public interface IRuleApplier {
@Nullable
IValidationSupport getValidationSupport();
@Nullable
default IAuthorizationSearchParamMatcher getSearchParamMatcher() {
return null;
};
}

View File

@ -581,6 +581,39 @@ public class RuleBuilder implements IAuthRuleBuilder {
return finished(rule);
}
@Override
public IAuthRuleFinished inCompartmentWithFilter(String theCompartmentName, IIdType theIdElement, String theFilter) {
// wipjv (resolved?) implemented
Validate.notBlank(theCompartmentName, "theCompartmentName must not be null");
Validate.notNull(theIdElement, "theOwner must not be null");
validateOwner(theIdElement);
// inlined from inCompartmentWithAdditionalSearchParams()
myClassifierType = ClassifierTypeEnum.IN_COMPARTMENT;
myInCompartmentName = theCompartmentName;
myAdditionalSearchParamsForCompartmentTypes = new AdditionalCompartmentSearchParameters();
Optional<RuleImplOp> oRule = findMatchingRule();
if (oRule.isPresent()) {
RuleImplOp rule = oRule.get();
rule.setAdditionalSearchParamsForCompartmentTypes(myAdditionalSearchParamsForCompartmentTypes);
rule.addClassifierCompartmentOwner(theIdElement);
return new RuleBuilderFinished(rule);
}
myInCompartmentOwners = Collections.singletonList(theIdElement);
FhirQueryRuleImpl rule = new FhirQueryRuleImpl(myRuleName);
rule.setFilter(theFilter);
return finished(rule);
}
@Override
public IAuthRuleFinished withFilter(String theFilter) {
myClassifierType = ClassifierTypeEnum.ANY_ID;
FhirQueryRuleImpl rule = new FhirQueryRuleImpl(myRuleName);
rule.setFilter(theFilter);
return finished(rule);
}
RuleBuilderFinished addInstances(Collection<IIdType> theInstances) {
myAppliesToInstances.addAll(theInstances);
return new RuleBuilderFinished(myRule);

View File

@ -24,7 +24,10 @@ import org.apache.commons.lang3.builder.ToStringStyle;
import org.hl7.fhir.instance.model.api.IBaseBundle;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collection;
@ -61,6 +64,7 @@ import static org.hl7.fhir.instance.model.api.IAnyResource.SP_RES_ID;
@SuppressWarnings("EnumSwitchStatementWhichMissesCases")
class RuleImplOp extends BaseRule /* implements IAuthRule */ {
private static final Logger ourLog = LoggerFactory.getLogger(RuleImplOp.class);
private AppliesTypeEnum myAppliesTo;
private Set<String> myAppliesToTypes;
@ -94,7 +98,7 @@ class RuleImplOp extends BaseRule /* implements IAuthRule */ {
public Verdict applyRule(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, IBaseResource theOutputResource,
IRuleApplier theRuleApplier, Set<AuthorizationFlagsEnum> theFlags, Pointcut thePointcut) {
FhirContext ctx = theRequestDetails.getServer().getFhirContext();
FhirContext ctx = theRequestDetails.getFhirContext();
RuleTarget target = new RuleTarget();
@ -311,6 +315,7 @@ class RuleImplOp extends BaseRule /* implements IAuthRule */ {
* TODO: At this point {@link RuleImplOp} handles "any ID" and "in compartment" logic - It would be nice to split these into separate classes.
*/
protected Verdict applyRuleLogic(RestOperationTypeEnum theOperation, RequestDetails theRequestDetails, IBaseResource theInputResource, IIdType theInputResourceId, IBaseResource theOutputResource, Set<AuthorizationFlagsEnum> theFlags, FhirContext theFhirContext, RuleTarget theRuleTarget, IRuleApplier theRuleApplier) {
ourLog.trace("applyRuleLogic {} {}", theOperation, theRuleTarget);
switch (myClassifierType) {
case ANY_ID:
break;
@ -672,6 +677,12 @@ class RuleImplOp extends BaseRule /* implements IAuthRule */ {
@Override
public String toString() {
ToStringBuilder builder = toStringBuilder();
return builder.toString();
}
@Nonnull
protected ToStringBuilder toStringBuilder() {
ToStringBuilder builder = new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE);
builder.append("op", myOp);
builder.append("transactionAppliesToOp", myTransactionAppliesToOp);
@ -680,7 +691,7 @@ class RuleImplOp extends BaseRule /* implements IAuthRule */ {
builder.append("classifierCompartmentName", myClassifierCompartmentName);
builder.append("classifierCompartmentOwners", myClassifierCompartmentOwners);
builder.append("classifierType", myClassifierType);
return builder.toString();
return builder;
}
void setAppliesToDeleteCascade(boolean theAppliesToDeleteCascade) {

View File

@ -0,0 +1,55 @@
package ca.uhn.fhir.rest.server.interceptor.consent;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor;
import ca.uhn.fhir.rest.server.interceptor.auth.IRuleApplier;
import ca.uhn.fhir.rest.server.interceptor.auth.PolicyEnum;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implement rule based search result filtering as a ConsentService.
*
* We have new rules that add fhir-query filters.
* We can't always merge these into the queries, this IConsentService
* removes bundle results that don't pass the filters.
* Otherwise, the final bundle result rule check will fail
* with a 403 on disallowed resources.
*/
public class RuleFilteringConsentService implements IConsentService {
private static final Logger ourLog = LoggerFactory.getLogger(RuleFilteringConsentService.class);
/** This happens during STORAGE_PREACCESS_RESOURCES */
private static final Pointcut CAN_SEE_POINTCUT = Pointcut.STORAGE_PREACCESS_RESOURCES;
/** Our delegate for consent verdicts */
protected final IRuleApplier myRuleApplier;
public RuleFilteringConsentService(IRuleApplier theRuleApplier) {
myRuleApplier = theRuleApplier;
}
/**
* Apply the rules active in our rule-applier, and drop resources that don't pass.
*
* @param theRequestDetails The current request.
* @param theResource The resource that will be exposed
* @param theContextServices Unused.
* @return REJECT if the rules don't ALLOW, PROCEED otherwise.
*/
@Override
public ConsentOutcome canSeeResource(RequestDetails theRequestDetails, IBaseResource theResource, IConsentContextServices theContextServices) {
ourLog.trace("canSeeResource() {} {}", theRequestDetails, theResource);
// apply rules! If yes, then yes!
AuthorizationInterceptor.Verdict ruleResult =
myRuleApplier.applyRulesAndReturnDecision(theRequestDetails.getRestOperationType(), theRequestDetails, null, null, theResource, CAN_SEE_POINTCUT);
if (ruleResult.getDecision() == PolicyEnum.ALLOW) {
// are these the right codes?
return ConsentOutcome.PROCEED;
} else {
return ConsentOutcome.REJECT;
}
}
}

View File

@ -0,0 +1,56 @@
package ca.uhn.fhir.rest.server.interceptor.consent;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor;
import ca.uhn.fhir.rest.server.interceptor.auth.IRuleApplier;
import ca.uhn.fhir.rest.server.interceptor.auth.PolicyEnum;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Very limited test since we can't reference real resources.
*/
@MockitoSettings(strictness = Strictness.LENIENT)
class RuleFilteringConsentServiceTest {
@Mock
IRuleApplier myRuleApplier;
RuleFilteringConsentService myRuleFilteringConsentService;
ServletRequestDetails myRequestDetails = new ServletRequestDetails();
@BeforeEach
void setUp() {
myRequestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE);
myRuleFilteringConsentService = new RuleFilteringConsentService(myRuleApplier);
}
@Test
void allowPasses() {
when(myRuleApplier.applyRulesAndReturnDecision(any(), any(), any(), any(), any(), any()))
.thenReturn(new AuthorizationInterceptor.Verdict(PolicyEnum.ALLOW, null));
ConsentOutcome consentDecision = myRuleFilteringConsentService.canSeeResource(myRequestDetails, null, null);
assertThat(consentDecision.getStatus(), equalTo(ConsentOperationStatusEnum.PROCEED));
}
@Test
void denyIsRejected() {
when(myRuleApplier.applyRulesAndReturnDecision(any(), any(), any(), any(), any(), any()))
.thenReturn(new AuthorizationInterceptor.Verdict(PolicyEnum.DENY, null));
ConsentOutcome consentDecision = myRuleFilteringConsentService.canSeeResource(myRequestDetails, null, null);
assertThat(consentDecision.getStatus(), equalTo(ConsentOperationStatusEnum.REJECT));
}
}

View File

@ -53,6 +53,8 @@ import static ca.uhn.fhir.jpa.model.util.JpaConstants.ALL_PARTITIONS_NAME;
* use the DEFAULT partition when partitioning is enabled.
*/
public class SystemRequestDetails extends RequestDetails {
private FhirContext myFhirContext;
public SystemRequestDetails() {
super(new MyInterceptorBroadcaster());
}
@ -93,7 +95,11 @@ public class SystemRequestDetails extends RequestDetails {
@Override
public FhirContext getFhirContext() {
return null;
return myFhirContext;
}
public void setFhirContext(FhirContext theFhirContext) {
myFhirContext = theFhirContext;
}
@Override

View File

@ -0,0 +1,78 @@
package ca.uhn.test.util;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.hamcrest.CustomTypeSafeMatcher;
import org.hamcrest.Matcher;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.slf4j.LoggerFactory;
/**
* Test helper to collect logback lines.
*
* The empty constructor will capture all log events, or you can name a log root to limit the noise.
*/
public class LogbackCaptureTestExtension implements BeforeEachCallback, AfterEachCallback {
private final Logger myLogger;
private final ListAppender<ILoggingEvent> myListAppender = new ListAppender<>();
/**
* @param theLoggerName the log name root to capture
*/
public LogbackCaptureTestExtension(String theLoggerName) {
myLogger = (Logger) LoggerFactory.getLogger(theLoggerName);
}
/**
* Capture the root logger - all lines.
*/
public LogbackCaptureTestExtension() {
this(org.slf4j.Logger.ROOT_LOGGER_NAME);
}
/**
* Direct reference to the list of events.
* You may clear() it, or whatever.
* @return the modifiable List of events captured so far.
*/
public java.util.List<ILoggingEvent> getLogEvents() {
return myListAppender.list;
}
@Override
public void beforeEach(ExtensionContext context) throws Exception {
myListAppender.start(); // SecurityContextHolder.getContext().setAuthentication(authResult);
myLogger.addAppender(myListAppender);
}
@Override
public void afterEach(ExtensionContext context) throws Exception {
myLogger.detachAppender(myListAppender);
}
public Matcher<ILoggingEvent> eventWithLevelAndMessageContains(Level theLevel, String thePartialMessage) {
return new LogbackEventMatcher("log event", theLevel, thePartialMessage);
}
public static class LogbackEventMatcher extends CustomTypeSafeMatcher<ILoggingEvent> {
private Level myLevel;
private String myString;
public LogbackEventMatcher(String description, Level theLevel, String theString) {
super(description);
myLevel = theLevel;
myString = theString;
}
@Override
protected boolean matchesSafely(ILoggingEvent item) {
return (myLevel == null || item.getLevel().isGreaterOrEqual(myLevel)) &&
item.getFormattedMessage().contains(myString);
}
}
}