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:
parent
b54b7b133e
commit
9aac4f9ba8
|
@ -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,6 +126,18 @@ public class AuthorizationInterceptors {
|
|||
}
|
||||
//END SNIPPET: patientAndAdmin
|
||||
|
||||
public void ruleFiltering() {
|
||||
RestfulServer restfulServer = new RestfulServer();
|
||||
AuthorizationInterceptor theAuthorizationInterceptor = new AuthorizationInterceptor();
|
||||
|
||||
//START SNIPPET: ruleFiltering
|
||||
ConsentInterceptor consentInterceptor = new ConsentInterceptor();
|
||||
consentInterceptor.registerConsentService(new RuleFilteringConsentService(theAuthorizationInterceptor));
|
||||
restfulServer.registerInterceptor(consentInterceptor);
|
||||
|
||||
//END SNIPPET: ruleFiltering
|
||||
}
|
||||
|
||||
|
||||
//START SNIPPET: conditionalUpdate
|
||||
@Update()
|
||||
|
@ -298,6 +313,7 @@ public class AuthorizationInterceptors {
|
|||
}
|
||||
|
||||
|
||||
|
||||
//START SNIPPET: narrowingByCode
|
||||
public class MyCodeSearchNarrowingInterceptor extends SearchNarrowingInterceptor {
|
||||
|
||||
|
|
|
@ -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."
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -194,6 +194,15 @@ public class InMemoryResourceMatcherR5Test {
|
|||
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
|
||||
public void testDateUnsupportedDateOps() {
|
||||
testDateUnsupportedDateOp(ParamPrefixEnum.APPROXIMATE);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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");
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -42,4 +42,9 @@ public interface IRuleApplier {
|
|||
|
||||
@Nullable
|
||||
IValidationSupport getValidationSupport();
|
||||
|
||||
@Nullable
|
||||
default IAuthorizationSearchParamMatcher getSearchParamMatcher() {
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue