Add SearchNarrowingInterceptor

This commit is contained in:
James Agnew 2019-01-09 20:20:46 -06:00
parent 415ed80522
commit ee52d6fb31
15 changed files with 850 additions and 123 deletions

View File

@ -1,15 +1,11 @@
package example; package example;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import java.util.List;
import org.hl7.fhir.dstu3.model.IdType;
import org.hl7.fhir.instance.model.api.IBaseResource;
import ca.uhn.fhir.model.dstu2.resource.Patient; import ca.uhn.fhir.model.dstu2.resource.Patient;
import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.model.primitive.IdDt;
import ca.uhn.fhir.rest.annotation.*; import ca.uhn.fhir.rest.annotation.ConditionalUrlParam;
import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.ResourceParam;
import ca.uhn.fhir.rest.annotation.Update;
import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.RequestDetails;
@ -17,6 +13,12 @@ import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor; import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor;
import ca.uhn.fhir.rest.server.interceptor.auth.*; import ca.uhn.fhir.rest.server.interceptor.auth.*;
import org.hl7.fhir.dstu3.model.IdType;
import org.hl7.fhir.instance.model.api.IBaseResource;
import java.util.List;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class AuthorizationInterceptors { public class AuthorizationInterceptors {
@ -158,4 +160,47 @@ public class AuthorizationInterceptors {
//END SNIPPET: patchAll //END SNIPPET: patchAll
} }
//START SNIPPET: narrowing
public class MyPatientSearchNarrowingInterceptor extends SearchNarrowingInterceptor {
/**
* This method must be overridden to provide the list of compartments
* and/or resources that the current user should have access to
*/
@Override
protected AuthorizedList buildAuthorizedList(RequestDetails theRequestDetails) {
// Process authorization header - The following is a fake
// implementation. Obviously we'd want something more real
// for a production scenario.
//
// In this basic example we have two hardcoded bearer tokens,
// one which is for a user that has access to one patient, and
// another that has full access.
String authHeader = theRequestDetails.getHeader("Authorization");
if ("Bearer dfw98h38r".equals(authHeader)) {
// This user will have access to two compartments
return new AuthorizedList()
.addCompartment("Patient/123")
.addCompartment("Patient/456");
} else if ("Bearer 39ff939jgg".equals(authHeader)) {
// This user has access to everything
return new AuthorizedList();
} else {
throw new AuthenticationException("Unknown bearer token");
}
}
}
//END SNIPPET: narrowing
} }

View File

@ -1,8 +1,10 @@
package ca.uhn.fhir.rest.gclient; package ca.uhn.fhir.rest.gclient;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank;
@ -97,7 +99,19 @@ public class ReferenceClientParam extends BaseClientParam implements IParam {
public ICriterion<ReferenceClientParam> hasAnyOfIds(Collection<String> theIds) { public ICriterion<ReferenceClientParam> hasAnyOfIds(Collection<String> theIds) {
return new StringCriterion<>(getParamName(), theIds); return new StringCriterion<>(getParamName(), theIds);
} }
/**
* Match the referenced resource if the resource has ANY of the given IDs
* (this is an OR search, not an AND search), (this can be the logical ID or
* the absolute URL of the resource). Note that to specify an AND search,
* simply add a subsequent {@link IQuery#where(ICriterion) where} criteria
* with the same parameter.
*/
public ICriterion<ReferenceClientParam> hasAnyOfIds(String... theIds) {
Validate.notNull(theIds, "theIds must not be null");
return hasAnyOfIds(Arrays.asList(theIds));
}
private static class ReferenceChainCriterion implements ICriterion<ReferenceClientParam>, ICriterionInternal { private static class ReferenceChainCriterion implements ICriterion<ReferenceClientParam>, ICriterionInternal {
private final String myResourceTypeQualifier; private final String myResourceTypeQualifier;

View File

@ -9,9 +9,9 @@ package ca.uhn.fhir.rest.param;
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -62,5 +62,10 @@ public abstract class BaseAndListParam<T extends IQueryParameterOr<?>> implement
return myValues.toString(); return myValues.toString();
} }
/**
* Returns the number of AND parameters
*/
public int size() {
return myValues.size();
}
} }

View File

@ -20,8 +20,10 @@ import org.hl7.fhir.instance.model.api.IPrimitiveType;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/* /*
* #%L * #%L
@ -208,6 +210,13 @@ public class ParameterUtil {
|| IPrimitiveType.class.isAssignableFrom(theClass); || IPrimitiveType.class.isAssignableFrom(theClass);
} }
public static String escapeAndJoinOrList(Collection<String> theValues) {
return theValues
.stream()
.map(ParameterUtil::escape)
.collect(Collectors.joining(","));
}
public static int nonEscapedIndexOf(String theString, char theCharacter) { public static int nonEscapedIndexOf(String theString, char theCharacter) {
for (int i = 0; i < theString.length(); i++) { for (int i = 0; i < theString.length(); i++) {
if (theString.charAt(i) == theCharacter) { if (theString.charAt(i) == theCharacter) {

View File

@ -29,14 +29,14 @@ public interface IAnyResource extends IBaseResource {
* Search parameter constant for <b>_language</b> * Search parameter constant for <b>_language</b>
*/ */
@SearchParamDefinition(name="_language", path="", description="The language of the resource", type="string" ) @SearchParamDefinition(name="_language", path="", description="The language of the resource", type="string" )
public static final String SP_RES_LANGUAGE = "_language"; String SP_RES_LANGUAGE = "_language";
/** /**
* Search parameter constant for <b>_id</b> * Search parameter constant for <b>_id</b>
*/ */
@SearchParamDefinition(name="_id", path="", description="The ID of the resource", type="token" ) @SearchParamDefinition(name="_id", path="", description="The ID of the resource", type="token" )
public static final String SP_RES_ID = "_id"; String SP_RES_ID = "_id";
/** /**
* <b>Fluent Client</b> search parameter constant for <b>_id</b> * <b>Fluent Client</b> search parameter constant for <b>_id</b>
@ -46,7 +46,7 @@ public interface IAnyResource extends IBaseResource {
* Path: <b>Resource._id</b><br> * Path: <b>Resource._id</b><br>
* </p> * </p>
*/ */
public static final TokenClientParam RES_ID = new TokenClientParam(IAnyResource.SP_RES_ID); TokenClientParam RES_ID = new TokenClientParam(IAnyResource.SP_RES_ID);
String getId(); String getId();
@ -55,11 +55,11 @@ public interface IAnyResource extends IBaseResource {
IPrimitiveType<String> getLanguageElement(); IPrimitiveType<String> getLanguageElement();
public Object getUserData(String name); Object getUserData(String name);
@Override @Override
IAnyResource setId(String theId); IAnyResource setId(String theId);
public void setUserData(String name, Object value); void setUserData(String name, Object value);
} }

View File

@ -81,7 +81,7 @@ public abstract class BaseQueryParameter implements IParameter {
String paramName = isNotBlank(qualifier) ? getName() + qualifier : getName(); String paramName = isNotBlank(qualifier) ? getName() + qualifier : getName();
List<String> paramValues = theTargetQueryArguments.get(paramName); List<String> paramValues = theTargetQueryArguments.get(paramName);
if (paramValues == null) { if (paramValues == null) {
paramValues = new ArrayList<String>(value.size()); paramValues = new ArrayList<>(value.size());
theTargetQueryArguments.put(paramName, paramValues); theTargetQueryArguments.put(paramName, paramValues);
} }

View File

@ -59,6 +59,10 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
</dependency>
</dependencies> </dependencies>

View File

@ -54,6 +54,8 @@ import static org.apache.commons.lang3.StringUtils.defaultString;
* <a href="http://jamesagnew.github.io/hapi-fhir/doc_rest_server_security.html">Documentation on Server Security</a> * <a href="http://jamesagnew.github.io/hapi-fhir/doc_rest_server_security.html">Documentation on Server Security</a>
* for information on how to use this interceptor. * for information on how to use this interceptor.
* </p> * </p>
*
* @see SearchNarrowingInterceptor
*/ */
public class AuthorizationInterceptor extends ServerOperationInterceptorAdapter implements IRuleApplier { public class AuthorizationInterceptor extends ServerOperationInterceptorAdapter implements IRuleApplier {

View File

@ -0,0 +1,84 @@
package ca.uhn.fhir.rest.server.interceptor.auth;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import org.apache.commons.lang3.Validate;
import java.util.ArrayList;
import java.util.List;
/**
* Return type for {@link SearchNarrowingInterceptor#buildAuthorizedList(RequestDetails)}
*/
public class AuthorizedList {
private List<String> myCompartments;
private List<String> myResources;
List<String> getCompartments() {
return myCompartments;
}
List<String> getResources() {
return myResources;
}
/**
* Adds a compartment that the user should be allowed to access
*
* @param theCompartment The compartment name, e.g. "Patient/123" (in this example the user would be allowed to access Patient/123 as well as Observations where Observation.subject="Patient/123"m, etc.
* @return Returns <code>this</code> for easy method chaining
*/
public AuthorizedList addCompartment(String theCompartment) {
Validate.notNull(theCompartment, "theCompartment must not be null");
if (myCompartments == null) {
myCompartments = new ArrayList<>();
}
myCompartments.add(theCompartment);
return this;
}
/**
* Adds a compartment that the user should be allowed to access
*
* @param theCompartments The compartment names, e.g. "Patient/123" (in this example the user would be allowed to access Patient/123 as well as Observations where Observation.subject="Patient/123"m, etc.
* @return Returns <code>this</code> for easy method chaining
*/
public AuthorizedList addCompartments(String... theCompartments) {
Validate.notNull(theCompartments, "theCompartments must not be null");
for (String next : theCompartments) {
addCompartment(next);
}
return this;
}
/**
* Adds a resource that the user should be allowed to access
*
* @param theResource The resource name, e.g. "Patient/123" (in this example the user would be allowed to access Patient/123 but not Observations where Observation.subject="Patient/123"m, etc.
* @return Returns <code>this</code> for easy method chaining
*/
public AuthorizedList addResource(String theResource) {
Validate.notNull(theResource, "theResource must not be null");
if (myResources == null) {
myResources = new ArrayList<>();
}
myResources.add(theResource);
return this;
}
/**
* Adds a resource that the user should be allowed to access
*
* @param theResources The resource names, e.g. "Patient/123" (in this example the user would be allowed to access Patient/123 but not Observations where Observation.subject="Patient/123"m, etc.
* @return Returns <code>this</code> for easy method chaining
*/
public AuthorizedList addResources(String... theResources) {
Validate.notNull(theResources, "theResources must not be null");
for (String next : theResources) {
addResource(next);
}
return this;
}
}

View File

@ -0,0 +1,198 @@
package ca.uhn.fhir.rest.server.interceptor.auth;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.rest.api.QualifiedParamList;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.param.ParameterUtil;
import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.interceptor.InterceptorAdapter;
import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
/**
* This interceptor can be used to automatically narrow the scope of searches in order to
* automatically restrict the searches to specific compartments.
* <p>
* For example, this interceptor
* could be used to restrict a user to only viewing data belonging to Patient/123 (i.e. data
* in the <code>Patient/123</code> compartment). In this case, a user performing a search
* for<br/>
* <code>http://baseurl/Observation?category=laboratory</code><br/>
* would receive results as though they had requested<br/>
* <code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code>
* </p>
* <p>
* Note that this interceptor should be used in combination with {@link AuthorizationInterceptor}
* if you are restricting results because of a security restriction. This interceptor is not
* intended to be a failsafe way of preventing users from seeing the wrong data (that is the
* purpose of AuthorizationInterceptor). This interceptor is simply intended as a convenience to
* help users simplify their queries while not receiving security errors for to trying to access
* data they do not have access to see.
* </p>
*
* @see AuthorizationInterceptor
*/
public abstract class SearchNarrowingInterceptor extends InterceptorAdapter {
/**
* Subclasses should override this method to supply the set of compartments that
* the user making the request should actually have access to.
* <p>
* Typically this is done by examining <code>theRequestDetails</code> to find
* out who the current user is and then building a list of Strings.
* </p>
*
* @param theRequestDetails The individual request currently being applied
*/
protected AuthorizedList buildAuthorizedList(@SuppressWarnings("unused") RequestDetails theRequestDetails) {
return new AuthorizedList();
}
@Override
public boolean incomingRequestPostProcessed(RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException {
// We don't support this operation type yet
Validate.isTrue(theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_SYSTEM);
if (theRequestDetails.getRestOperationType() != RestOperationTypeEnum.SEARCH_TYPE) {
return true;
}
FhirContext ctx = theRequestDetails.getServer().getFhirContext();
RuntimeResourceDefinition resDef = ctx.getResourceDefinition(theRequestDetails.getResourceName());
HashMap<String, List<String>> parameterToOrValues = new HashMap<>();
AuthorizedList authorizedList = buildAuthorizedList(theRequestDetails);
/*
* Create a map of search parameter values that need to be added to the
* given request
*/
Collection<String> compartments = authorizedList.getCompartments();
if (compartments != null) {
processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, compartments, true);
}
Collection<String> resources = authorizedList.getResources();
if (resources != null) {
processResourcesOrCompartments(theRequestDetails, resDef, parameterToOrValues, resources, false);
}
/*
* Add any param values to the actual request
*/
if (parameterToOrValues.size() > 0) {
Map<String, String[]> newParameters = new HashMap<>(theRequestDetails.getParameters());
for (Map.Entry<String, List<String>> nextEntry : parameterToOrValues.entrySet()) {
String nextParamName = nextEntry.getKey();
List<String> nextAllowedValues = nextEntry.getValue();
if (!newParameters.containsKey(nextParamName)) {
/*
* If we don't already have a parameter of the given type, add one
*/
String nextValuesJoined = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
String[] paramValues = {nextValuesJoined};
newParameters.put(nextParamName, paramValues);
} else {
/*
* If the client explicitly requested the given parameter already, we'll
* just update the request to have the intersection of the values that the client
* requested, and the values that the user is allowed to see
*/
String[] existingValues = newParameters.get(nextParamName);
boolean restrictedExistingList = false;
for (int i = 0; i < existingValues.length; i++) {
String nextExistingValue = existingValues[i];
List<String> nextRequestedValues = QualifiedParamList.splitQueryStringByCommasIgnoreEscape(null, nextExistingValue);
List<String> nextPermittedValues = ListUtils.intersection(nextRequestedValues, nextAllowedValues);
if (nextPermittedValues.size() > 0) {
restrictedExistingList = true;
existingValues[i] = ParameterUtil.escapeAndJoinOrList(nextPermittedValues);
}
}
/*
* If none of the values that were requested by the client overlap at all
* with the values that the user is allowed to see, we'll just add the permitted
* list as a new list. Ultimately this scenario actually means that the client
* shouldn't get *any* results back, and adding a new AND parameter (that doesn't
* overlap at all with the others) is one way of ensuring that.
*/
if (!restrictedExistingList) {
String[] newValues = Arrays.copyOf(existingValues, existingValues.length + 1);
newValues[existingValues.length] = ParameterUtil.escapeAndJoinOrList(nextAllowedValues);
newParameters.put(nextParamName, newValues);
}
}
}
theRequestDetails.setParameters(newParameters);
}
return true;
}
private void processResourcesOrCompartments(RequestDetails theRequestDetails, RuntimeResourceDefinition theResDef, HashMap<String, List<String>> theParameterToOrValues, Collection<String> theResourcesOrCompartments, boolean theAreCompartments) {
String lastCompartmentName = null;
String lastSearchParamName=null;
for (String nextCompartment : theResourcesOrCompartments) {
Validate.isTrue(StringUtils.countMatches(nextCompartment, '/') == 1, "Invalid compartment name (must be in form \"ResourceType/xxx\": %s", nextCompartment);
String compartmentName = nextCompartment.substring(0, nextCompartment.indexOf('/'));
String searchParamName = null;
if (compartmentName.equalsIgnoreCase(lastCompartmentName)) {
// Avoid doing a lookup for the same thing repeatedly
searchParamName = lastSearchParamName;
} else {
if (compartmentName.equalsIgnoreCase(theRequestDetails.getResourceName())) {
searchParamName = "_id";
} else if (theAreCompartments) {
List<RuntimeSearchParam> searchParams = theResDef.getSearchParamsForCompartmentName(compartmentName);
if (searchParams.size() > 0) {
// Resources like Observation have several fields that add the resource to
// the compartment. In the case of Observation, it's subject, patient and performer.
// For this kind of thing, we'll prefer the one called "patient".
RuntimeSearchParam searchParam =
searchParams
.stream()
.filter(t -> t.getName().equalsIgnoreCase(compartmentName))
.findFirst()
.orElse(searchParams.get(0));
searchParamName = searchParam.getName();
}
}
lastCompartmentName = compartmentName;
lastSearchParamName = searchParamName;
}
if (searchParamName != null) {
List<String> orValues = theParameterToOrValues.computeIfAbsent(searchParamName, t -> new ArrayList<>());
orValues.add(nextCompartment);
}
}
}
}

View File

@ -1,16 +1,19 @@
package ca.uhn.fhir.rest.client; package ca.uhn.fhir.rest.client;
import static org.junit.Assert.assertEquals; import ca.uhn.fhir.context.FhirContext;
import static org.junit.Assert.assertNotNull; import ca.uhn.fhir.model.api.Include;
import static org.junit.Assert.assertThat; import ca.uhn.fhir.rest.annotation.IncludeParam;
import static org.junit.Assert.assertTrue; import ca.uhn.fhir.rest.annotation.RequiredParam;
import static org.mockito.Mockito.mock; import ca.uhn.fhir.rest.annotation.Search;
import static org.mockito.Mockito.when; import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.client.api.IBasicClient;
import java.io.StringReader; import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
import java.nio.charset.Charset; import ca.uhn.fhir.rest.param.TokenAndListParam;
import java.util.*; import ca.uhn.fhir.rest.param.TokenOrListParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.util.TestUtil;
import com.google.common.base.Charsets;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.ReaderInputStream; import org.apache.commons.io.input.ReaderInputStream;
import org.apache.http.HttpResponse; import org.apache.http.HttpResponse;
@ -23,118 +26,144 @@ import org.apache.http.message.BasicStatusLine;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Encounter; import org.hl7.fhir.r4.model.Encounter;
import org.junit.*; import org.junit.AfterClass;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.internal.stubbing.defaultanswers.ReturnsDeepStubs; import org.mockito.internal.stubbing.defaultanswers.ReturnsDeepStubs;
import ca.uhn.fhir.context.FhirContext; import java.io.StringReader;
import ca.uhn.fhir.model.api.Include; import java.nio.charset.Charset;
import ca.uhn.fhir.rest.annotation.*; import java.util.HashSet;
import ca.uhn.fhir.rest.api.Constants; import java.util.List;
import ca.uhn.fhir.rest.client.api.IBasicClient; import java.util.Set;
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
import ca.uhn.fhir.rest.param.TokenOrListParam; import static org.junit.Assert.*;
import ca.uhn.fhir.rest.param.TokenParam; import static org.mockito.Mockito.mock;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; import static org.mockito.Mockito.when;
import ca.uhn.fhir.util.TestUtil;
public class SearchClientTest { public class SearchClientTest {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(SearchClientTest.class); private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(SearchClientTest.class);
private FhirContext ourCtx; private FhirContext ourCtx;
private HttpClient ourHttpClient; private HttpClient ourHttpClient;
private HttpResponse ourHttpResponse; private HttpResponse ourHttpResponse;
@Before @Before
public void before() { public void before() {
ourCtx = FhirContext.forR4(); ourCtx = FhirContext.forR4();
ourHttpClient = mock(HttpClient.class, new ReturnsDeepStubs()); ourHttpClient = mock(HttpClient.class, new ReturnsDeepStubs());
ourCtx.getRestfulClientFactory().setHttpClient(ourHttpClient); ourCtx.getRestfulClientFactory().setHttpClient(ourHttpClient);
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
ourHttpResponse = mock(HttpResponse.class, new ReturnsDeepStubs()); ourHttpResponse = mock(HttpResponse.class, new ReturnsDeepStubs());
} }
@Test @Test
public void testPostOnLongParamsList() throws Exception { public void testPostOnLongParamsList() throws Exception {
String resp = createBundle(); String resp = createBundle();
ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class); ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class);
when(ourHttpClient.execute(capt.capture())).thenReturn(ourHttpResponse); when(ourHttpClient.execute(capt.capture())).thenReturn(ourHttpResponse);
when(ourHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK")); when(ourHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
when(ourHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML_NEW + "; charset=UTF-8")); when(ourHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML_NEW + "; charset=UTF-8"));
when(ourHttpResponse.getEntity().getContent()).thenReturn(new ReaderInputStream(new StringReader(resp), Charset.forName("UTF-8"))); when(ourHttpResponse.getEntity().getContent()).thenAnswer(t->new ReaderInputStream(new StringReader(resp), Charset.forName("UTF-8")));
ITestClient client = ourCtx.newRestfulClient(ITestClient.class, "http://foo"); ITestClient client = ourCtx.newRestfulClient(ITestClient.class, "http://foo");
Set<Include> includes = new HashSet<Include>(); Set<Include> includes = new HashSet<Include>();
includes.add(new Include("one")); includes.add(new Include("one"));
includes.add(new Include("two")); includes.add(new Include("two"));
TokenOrListParam params = new TokenOrListParam(); TokenOrListParam params = new TokenOrListParam();
for (int i = 0; i < 1000; i++) { for (int i = 0; i < 1000; i++) {
params.add(new TokenParam("system", "value")); params.add(new TokenParam("system", "value"));
} }
List<Encounter> found = client.searchByList(params, includes);
assertEquals(1, found.size()); // With OR list
Encounter encounter = found.get(0); List<Encounter> found = client.searchByList(params, includes);
assertNotNull(encounter.getSubject().getReference());
HttpUriRequest value = capt.getValue();
assertTrue("Expected request of type POST on long params list", value instanceof HttpPost); assertEquals(1, found.size());
HttpPost post = (HttpPost) value;
String body = IOUtils.toString(post.getEntity().getContent());
ourLog.info(body);
assertThat(body, Matchers.containsString("_include=one"));
assertThat(body, Matchers.containsString("_include=two"));
}
@Test Encounter encounter = found.get(0);
public void testReturnTypedList() throws Exception { assertNotNull(encounter.getSubject().getReference());
HttpUriRequest value = capt.getValue();
String resp = createBundle();
ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class); assertTrue("Expected request of type POST on long params list", value instanceof HttpPost);
when(ourHttpClient.execute(capt.capture())).thenReturn(ourHttpResponse); HttpPost post = (HttpPost) value;
when(ourHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK")); String body = IOUtils.toString(post.getEntity().getContent(), Charsets.UTF_8);
when(ourHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML_NEW + "; charset=UTF-8")); ourLog.info(body);
when(ourHttpResponse.getEntity().getContent()).thenReturn(new ReaderInputStream(new StringReader(resp), Charset.forName("UTF-8"))); assertThat(body, Matchers.containsString("_include=one"));
assertThat(body, Matchers.containsString("_include=two"));
ITestClient client = ourCtx.newRestfulClient(ITestClient.class, "http://foo"); // With AND list
List<Encounter> found = client.search();
assertEquals(1, found.size());
Encounter encounter = found.get(0); TokenAndListParam paramsAndList = new TokenAndListParam();
assertNotNull(encounter.getSubject().getReference()); paramsAndList.addAnd(params);
} found = client.searchByList(paramsAndList, includes);
private String createBundle() { assertEquals(1, found.size());
Bundle bundle = new Bundle();
Encounter enc = new Encounter();
enc.getSubject().setReference("Patient/1");
bundle.addEntry().setResource(enc);
String retVal = ourCtx.newXmlParser().encodeResourceToString(bundle);
return retVal;
}
private interface ITestClient extends IBasicClient { encounter = found.get(0);
assertNotNull(encounter.getSubject().getReference());
value = capt.getAllValues().get(1);
@Search assertTrue("Expected request of type POST on long params list", value instanceof HttpPost);
List<Encounter> search(); post = (HttpPost) value;
body = IOUtils.toString(post.getEntity().getContent(), Charsets.UTF_8);
ourLog.info(body);
assertThat(body, Matchers.containsString("_include=one"));
assertThat(body, Matchers.containsString("_include=two"));
}
@Search @Test
List<Encounter> searchByList(@RequiredParam(name = Encounter.SP_IDENTIFIER) TokenOrListParam tokenOrListParam, @IncludeParam Set<Include> theIncludes) throws BaseServerResponseException; public void testReturnTypedList() throws Exception {
} String resp = createBundle();
@AfterClass ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class);
public static void afterClassClearContext() { when(ourHttpClient.execute(capt.capture())).thenReturn(ourHttpResponse);
TestUtil.clearAllStaticFieldsForUnitTest(); when(ourHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
} when(ourHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML_NEW + "; charset=UTF-8"));
when(ourHttpResponse.getEntity().getContent()).thenReturn(new ReaderInputStream(new StringReader(resp), Charset.forName("UTF-8")));
ITestClient client = ourCtx.newRestfulClient(ITestClient.class, "http://foo");
List<Encounter> found = client.search();
assertEquals(1, found.size());
Encounter encounter = found.get(0);
assertNotNull(encounter.getSubject().getReference());
}
private String createBundle() {
Bundle bundle = new Bundle();
Encounter enc = new Encounter();
enc.getSubject().setReference("Patient/1");
bundle.addEntry().setResource(enc);
String retVal = ourCtx.newXmlParser().encodeResourceToString(bundle);
return retVal;
}
private interface ITestClient extends IBasicClient {
@Search
List<Encounter> search();
@Search
List<Encounter> searchByList(@RequiredParam(name = Encounter.SP_IDENTIFIER) TokenOrListParam tokenOrListParam, @IncludeParam Set<Include> theIncludes) throws BaseServerResponseException;
@Search
List<Encounter> searchByList(@RequiredParam(name = Encounter.SP_IDENTIFIER) TokenAndListParam tokenOrListParam, @IncludeParam Set<Include> theIncludes) throws BaseServerResponseException;
}
@AfterClass
public static void afterClassClearContext() {
TestUtil.clearAllStaticFieldsForUnitTest();
}
} }

View File

@ -0,0 +1,300 @@
package ca.uhn.fhir.rest.server.interceptor.auth;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.model.api.IQueryParameterOr;
import ca.uhn.fhir.model.api.IQueryParameterType;
import ca.uhn.fhir.rest.annotation.OptionalParam;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
import ca.uhn.fhir.rest.param.BaseAndListParam;
import ca.uhn.fhir.rest.param.ReferenceAndListParam;
import ca.uhn.fhir.rest.param.StringAndListParam;
import ca.uhn.fhir.rest.param.TokenAndListParam;
import ca.uhn.fhir.rest.server.FifoMemoryPagingProvider;
import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
import org.apache.commons.lang3.Validate;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hamcrest.Matchers;
import org.hl7.fhir.instance.model.api.IAnyResource;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Observation;
import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.Resource;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
public class SearchNarrowingInterceptorTest {
private static String ourLastHitMethod;
private static FhirContext ourCtx;
private static TokenAndListParam ourLastIdParam;
private static TokenAndListParam ourLastCodeParam;
private static ReferenceAndListParam ourLastSubjectParam;
private static ReferenceAndListParam ourLastPatientParam;
private static ReferenceAndListParam ourLastPerformerParam;
private static StringAndListParam ourLastNameParam;
private static List<Resource> ourReturn;
private static Server ourServer;
private static IGenericClient ourClient;
private static AuthorizedList ourNextCompartmentList;
@Before
public void before() {
ourLastHitMethod = null;
ourReturn = Collections.emptyList();
ourLastIdParam = null;
ourLastNameParam = null;
ourLastSubjectParam = null;
ourLastPatientParam = null;
ourLastPerformerParam = null;
ourLastCodeParam = null;
ourNextCompartmentList = null;
}
@Test
public void testNarrowObservationsByPatientContext_ClientRequestedNoParams() {
ourNextCompartmentList = new AuthorizedList().addCompartments("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Observation")
.execute();
assertEquals("Observation.search", ourLastHitMethod);
assertNull(ourLastIdParam);
assertNull(ourLastCodeParam);
assertNull(ourLastSubjectParam);
assertNull(ourLastPerformerParam);
assertThat(toStrings(ourLastPatientParam), Matchers.contains("Patient/123,Patient/456"));
}
/**
* Should not make any changes
*/
@Test
public void testNarrowObservationsByPatientResources_ClientRequestedNoParams() {
ourNextCompartmentList = new AuthorizedList().addResources("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Observation")
.execute();
assertEquals("Observation.search", ourLastHitMethod);
assertNull(ourLastIdParam);
assertNull(ourLastCodeParam);
assertNull(ourLastSubjectParam);
assertNull(ourLastPerformerParam);
assertNull(ourLastPatientParam);
}
@Test
public void testNarrowPatientByPatientResources_ClientRequestedNoParams() {
ourNextCompartmentList = new AuthorizedList().addResources("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Patient")
.execute();
assertEquals("Patient.search", ourLastHitMethod);
assertNull(ourLastCodeParam);
assertNull(ourLastSubjectParam);
assertNull(ourLastPerformerParam);
assertNull(ourLastPatientParam);
assertThat(toStrings(ourLastIdParam), Matchers.contains("Patient/123,Patient/456"));
}
@Test
public void testNarrowPatientByPatientContext_ClientRequestedNoParams() {
ourNextCompartmentList = new AuthorizedList().addCompartments("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Patient")
.execute();
assertEquals("Patient.search", ourLastHitMethod);
assertNull(ourLastNameParam);
assertThat(toStrings(ourLastIdParam), Matchers.contains("Patient/123,Patient/456"));
}
@Test
public void testNarrowPatientByPatientContext_ClientRequestedSomeOverlap() {
ourNextCompartmentList = new AuthorizedList().addCompartments("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Patient")
.where(IAnyResource.RES_ID.exactly().codes("Patient/123", "Patient/999"))
.execute();
assertEquals("Patient.search", ourLastHitMethod);
assertNull(ourLastNameParam);
assertThat(toStrings(ourLastIdParam), Matchers.contains("Patient/123"));
}
@Test
public void testNarrowObservationsByPatientContext_ClientRequestedSomeOverlap() {
ourNextCompartmentList = new AuthorizedList().addCompartments("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Observation")
.where(Observation.PATIENT.hasAnyOfIds("Patient/456", "Patient/777"))
.and(Observation.PATIENT.hasAnyOfIds("Patient/456", "Patient/888"))
.execute();
assertEquals("Observation.search", ourLastHitMethod);
assertNull(ourLastIdParam);
assertNull(ourLastCodeParam);
assertNull(ourLastSubjectParam);
assertNull(ourLastPerformerParam);
assertThat(toStrings(ourLastPatientParam), Matchers.contains("Patient/456", "Patient/456"));
}
@Test
public void testNarrowObservationsByPatientContext_ClientRequestedNoOverlap() {
ourNextCompartmentList = new AuthorizedList().addCompartments("Patient/123", "Patient/456");
ourClient
.search()
.forResource("Observation")
.where(Observation.PATIENT.hasAnyOfIds("Patient/111", "Patient/777"))
.and(Observation.PATIENT.hasAnyOfIds("Patient/111", "Patient/888"))
.execute();
assertEquals("Observation.search", ourLastHitMethod);
assertNull(ourLastIdParam);
assertNull(ourLastCodeParam);
assertNull(ourLastSubjectParam);
assertNull(ourLastPerformerParam);
assertThat(toStrings(ourLastPatientParam), Matchers.contains("Patient/111,Patient/777", "Patient/111,Patient/888", "Patient/123,Patient/456"));
}
private List<String> toStrings(BaseAndListParam<? extends IQueryParameterOr<?>> theParams) {
List<? extends IQueryParameterOr<? extends IQueryParameterType>> valuesAsQueryTokens = theParams.getValuesAsQueryTokens();
return valuesAsQueryTokens
.stream()
.map(IQueryParameterOr::getValuesAsQueryTokens)
.map(t -> t
.stream()
.map(j -> j.getValueAsQueryToken(ourCtx))
.collect(Collectors.joining(",")))
.collect(Collectors.toList());
}
public static class DummyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Search()
public List<Resource> search(
@OptionalParam(name = "_id") TokenAndListParam theIdParam,
@OptionalParam(name = "name") StringAndListParam theNameParam
) {
ourLastHitMethod = "Patient.search";
ourLastIdParam = theIdParam;
ourLastNameParam = theNameParam;
return ourReturn;
}
}
public static class DummyObservationResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Observation.class;
}
@Search()
public List<Resource> search(
@OptionalParam(name = "_id") TokenAndListParam theIdParam,
@OptionalParam(name = Observation.SP_SUBJECT) ReferenceAndListParam theSubjectParam,
@OptionalParam(name = Observation.SP_PATIENT) ReferenceAndListParam thePatientParam,
@OptionalParam(name = Observation.SP_PERFORMER) ReferenceAndListParam thePerformerParam,
@OptionalParam(name = "code") TokenAndListParam theCodeParam
) {
ourLastHitMethod = "Observation.search";
ourLastIdParam = theIdParam;
ourLastSubjectParam = theSubjectParam;
ourLastPatientParam = thePatientParam;
ourLastPerformerParam = thePerformerParam;
ourLastCodeParam = theCodeParam;
return ourReturn;
}
}
private static class MySearchNarrowingInterceptor extends SearchNarrowingInterceptor {
@Override
protected AuthorizedList buildAuthorizedList(RequestDetails theRequestDetails) {
Validate.notNull(ourNextCompartmentList);
return ourNextCompartmentList;
}
}
@AfterClass
public static void afterClassClearContext() throws Exception {
ourServer.stop();
TestUtil.clearAllStaticFieldsForUnitTest();
}
@BeforeClass
public static void beforeClass() throws Exception {
ourCtx = FhirContext.forR4();
int ourPort = PortUtil.findFreePort();
ourServer = new Server(ourPort);
DummyPatientResourceProvider patProvider = new DummyPatientResourceProvider();
DummyObservationResourceProvider obsProv = new DummyObservationResourceProvider();
ServletHandler proxyHandler = new ServletHandler();
RestfulServer ourServlet = new RestfulServer(ourCtx);
ourServlet.setFhirContext(ourCtx);
ourServlet.setResourceProviders(patProvider, obsProv);
ourServlet.setPagingProvider(new FifoMemoryPagingProvider(100));
ourServlet.registerInterceptor(new MySearchNarrowingInterceptor());
ServletHolder servletHolder = new ServletHolder(ourServlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
ourServer.start();
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
ourCtx.getRestfulClientFactory().setSocketTimeout(1000000);
ourClient = ourCtx.newRestfulGenericClient("http://localhost:" + ourPort);
}
}

View File

@ -519,6 +519,7 @@
<commons_lang3_version>3.8.1</commons_lang3_version> <commons_lang3_version>3.8.1</commons_lang3_version>
<derby_version>10.14.2.0</derby_version> <derby_version>10.14.2.0</derby_version>
<error_prone_annotations_version>2.0.18</error_prone_annotations_version> <error_prone_annotations_version>2.0.18</error_prone_annotations_version>
<error_prone_core_version>2.3.2</error_prone_core_version>
<guava_version>25.0-jre</guava_version> <guava_version>25.0-jre</guava_version>
<gson_version>2.8.5</gson_version> <gson_version>2.8.5</gson_version>
<jaxb_bundle_version>2.2.11_1</jaxb_bundle_version> <jaxb_bundle_version>2.2.11_1</jaxb_bundle_version>
@ -639,7 +640,7 @@
<dependency> <dependency>
<groupId>com.google.errorprone</groupId> <groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId> <artifactId>error_prone_core</artifactId>
<version>2.3.2</version> <version>${error_prone_core_version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.google.guava</groupId> <groupId>com.google.guava</groupId>
@ -2308,7 +2309,7 @@
<path> <path>
<groupId>com.google.errorprone</groupId> <groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId> <artifactId>error_prone_core</artifactId>
<version>2.3.2</version> <version>${error_prone_core_version}</version>
</path> </path>
</annotationProcessorPaths> </annotationProcessorPaths>
</configuration> </configuration>

View File

@ -264,6 +264,12 @@
OperationDefinitions are now created for named queries in server OperationDefinitions are now created for named queries in server
module. Thanks to Stig Døssing for the pull request! module. Thanks to Stig Døssing for the pull request!
</action> </action>
<action type="add">
A new server interceptor has been added called "SearchNarrowingInterceptor".
This interceptor can be used to automatically narrow the scope of searches
performed by the user to limit them to specific resources or compartments
that the user should have access to.
</action>
</release> </release>
<release version="3.6.0" date="2018-11-12" description="Food"> <release version="3.6.0" date="2018-11-12" description="Food">
<action type="add"> <action type="add">

View File

@ -96,10 +96,10 @@
</p> </p>
<p class="doc_info_bubble"> <p class="doc_info_bubble">
AuthorizationInterceptor is a new feature in HAPI FHIR, and has not yet AuthorizationInterceptor has been well tested, but it is impossible to
been heavily tested. Use with caution, and do lots of testing! We welcome predeict every scenario and environment in which HAPI FHIR will be used.
feedback and suggestions on this feature. In addition, this documentation is Use with caution, and do lots of testing! We welcome
not yet complete. More examples and details will be added soon! Please get in feedback and suggestions on this feature. Please get in
touch if you'd like to help test, have suggestions, etc. touch if you'd like to help test, have suggestions, etc.
</p> </p>
@ -253,7 +253,37 @@
</subsection> </subsection>
</section> </section>
<section name="Search Narrowind">
<p>
HAPI FHIR 3.7.0 introduced a new interceptor, the
<a href="./apidocs/ca/uhn/fhir/rest/server/interceptor/auth/SearchNarrowingInterceptor.html">SearchNarrowingInterceptor</a>.
</p>
<p>
This interceptor is designed to be used in conjunction with AuthorizationInterceptor. It
uses a similar strategy where a dynamic list is built up for each request, but the
purpose of this interceptor is to modify client searches that are received (after
HAPI FHIR received the HTTP request, but before the search is actually performed)
to restrict the search to only search for specific resources or compartments that the
user has access to.
</p>
<p>
This could be used, for example, to allow the user to perform a search for<br/>
<code>http://baseurl/Observation?category=laboratory</code><br/>
and then receive results as though they had requested<br/>
<code>http://baseurl/Observation?subject=Patient/123&category=laboratory</code>.
</p>
<p>
An example of this interceptor follows:
</p>
<macro name="snippet">
<param name="id" value="narrowing" />
<param name="file" value="examples/src/main/java/example/AuthorizationInterceptors.java" />
</macro>
</section>
</body> </body>
</document> </document>