diff --git a/fhir-server/src/main/java/com/ibm/fhir/server/interceptor/FHIRPersistenceInterceptorMgr.java b/fhir-server/src/main/java/com/ibm/fhir/server/interceptor/FHIRPersistenceInterceptorMgr.java index 05e1939e9e8..e4b27dc87e4 100644 --- a/fhir-server/src/main/java/com/ibm/fhir/server/interceptor/FHIRPersistenceInterceptorMgr.java +++ b/fhir-server/src/main/java/com/ibm/fhir/server/interceptor/FHIRPersistenceInterceptorMgr.java @@ -1,5 +1,5 @@ /* - * (C) Copyright IBM Corp. 2016, 2021 + * (C) Copyright IBM Corp. 2016, 2022 * * SPDX-License-Identifier: Apache-2.0 */ @@ -10,10 +10,8 @@ import java.util.List; import java.util.ServiceLoader; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.logging.Level; import java.util.logging.Logger; -import com.ibm.fhir.core.FHIRUtilities; import com.ibm.fhir.persistence.context.FHIRPersistenceEvent; import com.ibm.fhir.server.spi.interceptor.FHIRPersistenceInterceptor; import com.ibm.fhir.server.spi.interceptor.FHIRPersistenceInterceptorException; @@ -23,7 +21,7 @@ * This class implements the FHIR persistence interceptor framework. This framework allows users to inject business * logic into the REST API request processing code path at various points. * - * Interceptors are discovered using the jdk's ServiceProvider class. + * Interceptors are discovered using the jdk's ServiceLoader class. * * To register an interceptor implementation, develop a class that implements the FHIRPersistenceInterceptor interface, * and then insert your implementation class name into a file called @@ -46,18 +44,11 @@ private FHIRPersistenceInterceptorMgr() { // Discover all implementations of our interceptor interface, then add them to our list of interceptors. ServiceLoader slList = ServiceLoader.load(FHIRPersistenceInterceptor.class); Iterator iter = slList.iterator(); - if (iter.hasNext()) { - log.fine("Discovered the following persistence interceptors:"); - while (iter.hasNext()) { - FHIRPersistenceInterceptor interceptor = iter.next(); - if (log.isLoggable(Level.FINE)) { - log.fine(">>> " + interceptor.getClass().getName() + '@' + FHIRUtilities.getObjectHandle(interceptor)); - } - interceptors.add(interceptor); - } - } else { - log.fine("No persistence interceptors found..."); + while (iter.hasNext()) { + FHIRPersistenceInterceptor interceptor = iter.next(); + interceptors.add(interceptor); } + log.info("Loaded the following persistence interceptors: " + interceptors); } /** @@ -66,9 +57,7 @@ private FHIRPersistenceInterceptorMgr() { * @param interceptor persistence interceptor to be registered */ public void addInterceptor(FHIRPersistenceInterceptor interceptor) { - if (log.isLoggable(Level.FINE)) { - log.fine("Registering persistence interceptor: " + interceptor.getClass().getName() + '@' + FHIRUtilities.getObjectHandle(interceptor)); - } + log.info("Registering persistence interceptor: " + interceptor); interceptors.add(interceptor); } @@ -78,9 +67,7 @@ public void addInterceptor(FHIRPersistenceInterceptor interceptor) { * @param interceptor persistence interceptor to be registered */ public void addPrioritizedInterceptor(FHIRPersistenceInterceptor interceptor) { - if (log.isLoggable(Level.FINE)) { - log.fine("Registering persistence interceptor: " + interceptor.getClass().getName() + '@' + FHIRUtilities.getObjectHandle(interceptor)); - } + log.info("Registering prioritized persistence interceptor: " + interceptor); interceptors.add(0, interceptor); } diff --git a/fhir-smart/src/main/java/com/ibm/fhir/smart/AuthzPolicyEnforcementPersistenceInterceptor.java b/fhir-smart/src/main/java/com/ibm/fhir/smart/AuthzPolicyEnforcementPersistenceInterceptor.java index d8218ee3b33..a975fe1c347 100644 --- a/fhir-smart/src/main/java/com/ibm/fhir/smart/AuthzPolicyEnforcementPersistenceInterceptor.java +++ b/fhir-smart/src/main/java/com/ibm/fhir/smart/AuthzPolicyEnforcementPersistenceInterceptor.java @@ -70,6 +70,29 @@ import jakarta.json.JsonReaderFactory; import jakarta.json.JsonValue; +/** + * A persistence interceptor that enforces authorization policy based on a JWT access token with SMART-on-FHIR scopes. + * + *

SMART App Launch: Scopes and Launch Context + * defines the following pattern for the OAuth 2.0 scopes expected in the JWT: + *

+ * ( 'patient' | 'user' ) '/' ( fhir-resource | '*' ) '.' ( 'read' | 'write' | '*' )`
+ * 
+ * + *

SMART Backend Services + * extends that to include an additional context type for 'system'. + * + *

This interceptor supports both flavors. + *

Before and after each interaction, as appropriate, the Authorization header + * is checked for a scope that permits the requested interaction. If the scope that permits the interaction is of + * context type 'patient' then the interceptor looks for a {@code patient_id} claim in the access token. + *

    + *
  1. For search interactions targeting resource types that can be in a patient compartment, the search is automatically + * scoped to the Patient compartment(s) of the id(s) in the patient_id claim. + *
  2. For any interaction that returns resources, if the resource type can be in a patient compartment then the interceptor + * ensures that it is in the compartment of the the id(s) passed in the patient_id claim. + *
+ */ public class AuthzPolicyEnforcementPersistenceInterceptor implements FHIRPersistenceInterceptor { private static final Logger log = Logger.getLogger(AuthzPolicyEnforcementPersistenceInterceptor.class.getName()); @@ -78,6 +101,7 @@ public class AuthzPolicyEnforcementPersistenceInterceptor implements FHIRPersist private static final String BEARER_TOKEN_PREFIX = "Bearer"; private static final String PATIENT = "Patient"; + private static final String RESOURCE = "Resource"; private static final String REQUEST_NOT_PERMITTED = "Requested interaction is not permitted by any of the passed scopes."; @@ -231,7 +255,18 @@ public void beforeRead(FHIRPersistenceEvent event) throws FHIRPersistenceInterce if (PATIENT.equals(resourceType)) { enforceDirectPatientAccess(resourceType, event.getFhirResourceId(), jwt); } else { - checkScopes(resourceType, Permission.READ, getScopesFromToken(jwt)); + ContextType approvedContext = checkScopes(resourceType, Permission.READ, getScopesFromToken(jwt)); + if (approvedContext == ContextType.PATIENT) { + assertPatientIdClaimIsValued(jwt); + } + } + } + + private void assertPatientIdClaimIsValued(DecodedJWT jwt) throws FHIRPersistenceInterceptorException { + if (jwt.getClaim("patient_id").isNull()) { + String msg = "Access token is missing 'patient_id' claim"; + throw new FHIRPersistenceInterceptorException(msg) + .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); } } @@ -243,7 +278,10 @@ public void beforeVread(FHIRPersistenceEvent event) throws FHIRPersistenceInterc if (PATIENT.equals(resourceType)) { enforceDirectPatientAccess(resourceType, event.getFhirResourceId(), jwt); } else { - checkScopes(resourceType, Permission.READ, getScopesFromToken(jwt)); + ContextType approvedContext = checkScopes(resourceType, Permission.READ, getScopesFromToken(jwt)); + if (approvedContext == ContextType.PATIENT) { + assertPatientIdClaimIsValued(jwt); + } } } @@ -254,12 +292,15 @@ public void beforeHistory(FHIRPersistenceEvent event) throws FHIRPersistenceInte if (event.getFhirResourceId() == null) { // System/type-level history - enforceSystemHistoryAccess(resourceType, event.getSystemHistoryContextImpl().getResourceTypes(), jwt); + enforceSystemLevelAccess(resourceType, event.getSystemHistoryContextImpl().getResourceTypes(), jwt); } else if (PATIENT.equals(resourceType)) { // For a Patient resource instance, check scopes for direct patient access enforceDirectPatientAccess(resourceType, event.getFhirResourceId(), jwt); } else { - checkScopes(resourceType, Permission.READ, getScopesFromToken(jwt)); + ContextType approvedContext = checkScopes(resourceType, Permission.READ, getScopesFromToken(jwt)); + if (approvedContext == ContextType.PATIENT) { + assertPatientIdClaimIsValued(jwt); + } } } @@ -292,28 +333,36 @@ private void enforceDirectPatientAccess(String resourceType, String resourceId, } } - private void enforceSystemHistoryAccess(String resourceType, List types, DecodedJWT jwt) throws FHIRPersistenceInterceptorException { + private void enforceSystemLevelAccess(String resourceType, List types, DecodedJWT jwt) throws FHIRPersistenceInterceptorException { Map> groupedScopes = getScopesFromToken(jwt); // Check for user or system access to the resource types - // If types specified, then check each of those, otherwise check the single type (which may be 'Resource') - boolean deny = false; - List typesToCheck = types.isEmpty() ? Arrays.asList(resourceType) : types; - for (String type : typesToCheck) { - if (!isApprovedByScopes(type, Permission.READ, groupedScopes.get(ContextType.USER)) && - !isApprovedByScopes(type, Permission.READ, groupedScopes.get(ContextType.SYSTEM))) { - deny = true; + // If types specified, then check each of those, otherwise check the single type (which will be 'Resource') + if (types.isEmpty() && RESOURCE.equals(resourceType)) { + if (!isApprovedByScopes(resourceType, Permission.READ, groupedScopes.get(ContextType.USER)) && + !isApprovedByScopes(resourceType, Permission.READ, groupedScopes.get(ContextType.SYSTEM))) { + String msg = "Whole-system interactions require a user or system scope with a wildcard resource type: " + + "('user'|'system') '/' '*' '.' ( 'read' | '*' )"; + if (log.isLoggable(Level.FINE)) { + log.fine(msg); + } + throw new FHIRPersistenceInterceptorException(msg) + .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); } - } - - if (deny) { - String msg = "Read permission for system history of '" + typesToCheck + - "' is not granted by any of the provided scopes: " + groupedScopes.values(); - if (log.isLoggable(Level.FINE)) { - log.fine(msg); + } else { + List typesToCheck = types.isEmpty() ? Arrays.asList(resourceType) : types; + for (String type : typesToCheck) { + if (!isApprovedByScopes(type, Permission.READ, groupedScopes.get(ContextType.USER)) && + !isApprovedByScopes(type, Permission.READ, groupedScopes.get(ContextType.SYSTEM))) { + String msg = "Read permission for system-level interaction with type '" + type + + "' is not granted by any of the provided scopes: " + groupedScopes.values(); + if (log.isLoggable(Level.FINE)) { + log.fine(msg); + } + throw new FHIRPersistenceInterceptorException(msg) + .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); + } } - throw new FHIRPersistenceInterceptorException(msg) - .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); } } @@ -324,9 +373,16 @@ private void enforceSystemHistoryAccess(String resourceType, List types, */ @Override public void beforeSearch(FHIRPersistenceEvent event) throws FHIRPersistenceInterceptorException { + String resourceType = event.getFhirResourceType(); DecodedJWT jwt = JWT.decode(getAccessToken()); - ContextType approvedContext = checkScopes(event.getFhirResourceType(), Permission.READ, getScopesFromToken(jwt)); + if (RESOURCE.equals(resourceType)) { + // System-level search + enforceSystemLevelAccess(resourceType, event.getSystemHistoryContextImpl().getResourceTypes(), jwt); + return; + } + + ContextType approvedContext = checkScopes(event.getFhirResourceType(), Permission.READ, getScopesFromToken(jwt)); if (approvedContext == ContextType.SYSTEM || approvedContext == ContextType.USER) { return; } @@ -334,70 +390,69 @@ public void beforeSearch(FHIRPersistenceEvent event) throws FHIRPersistenceInter // if the interaction is granted by PATIENT (and not SYSTEM or USER) // then scope the search to the compartment(s) of the in-context patient(s) FHIRSearchContext searchContext = event.getSearchContextImpl(); - if (searchContext != null) { - Set patientIdFromToken = getPatientIdFromToken(jwt); + assertPatientIdClaimIsValued(jwt); + Set patientIdFromToken = getPatientIdFromToken(jwt); - // Determine if compartment search - String compartment = null; - String compartmentId = null; - for (QueryParameter queryParameter : searchContext.getSearchParameters()) { - if (queryParameter.isInclusionCriteria()) { - // Value will contain a reference to compartment - String[] tokens = queryParameter.getValues().get(0).getValueString().split("/"); - compartment = tokens[0]; - compartmentId = tokens[1]; - break; - } + // Determine if compartment search + String compartment = null; + String compartmentId = null; + for (QueryParameter queryParameter : searchContext.getSearchParameters()) { + if (queryParameter.isInclusionCriteria()) { + // Value will contain a reference to compartment + String[] tokens = queryParameter.getValues().get(0).getValueString().split("/"); + compartment = tokens[0]; + compartmentId = tokens[1]; + break; } + } - if (compartment != null) { - // Compartment search - validate patient access - switch(compartment) { - case "Patient": - if (!patientIdFromToken.contains(compartmentId)) { - String msg = "Interaction with 'Patient/" + compartmentId + "' is not permitted for patient context " + patientIdFromToken; - throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); - } - break; - case "Encounter": - Resource r = executeRead(event.getPersistenceImpl(), ModelSupport.getResourceType(compartment), compartmentId); - if (!isInCompartment(r, CompartmentType.PATIENT, patientIdFromToken)) { - String msg = "Interaction with 'Encounter/" + compartmentId + "' is not permitted for patient context " + patientIdFromToken; - throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); - } - break; - case "Device": - case "Practitioner": - case "RelatedPerson": - String msg = "Compartment search for compartment type '" + compartment + "' is not permitted."; + if (compartment != null) { + // Compartment search - validate patient access + switch(compartment) { + case "Patient": + if (!patientIdFromToken.contains(compartmentId)) { + String msg = "Interaction with 'Patient/" + compartmentId + "' is not permitted for patient context " + patientIdFromToken; throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); - default: - // If the operator has extended the server with custom compartment definitions, they will need to protect those separately } - - if (log.isLoggable(Level.FINE)) { - log.fine("Performing compartment search for compartment '" + compartment + "/" + compartmentId + "'."); + break; + case "Encounter": + Resource r = executeRead(event.getPersistenceImpl(), ModelSupport.getResourceType(compartment), compartmentId); + if (!isInCompartment(r, CompartmentType.PATIENT, patientIdFromToken)) { + String msg = "Interaction with 'Encounter/" + compartmentId + "' is not permitted for patient context " + patientIdFromToken; + throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); } - } else { - // Not compartment search - validate and convert to Patient compartment search if the resource type can be in the Patient compartment - try { - if (compartmentHelper.getCompartmentResourceTypes(PATIENT).contains(event.getFhirResourceType())) { - // Special case for the List resource because we want to enable searches for Formulary Coverage Plan lists - if ("List".equals(event.getFhirResourceType())) { - // In this case, we will rely on the afterSearch logic to prevent unauthorized access to patient-scoped Lists - return; - } - - // Build the Patient compartment inclusion criteria search parameter - QueryParameter inclusionCriteria = searchHelper.buildInclusionCriteria(PATIENT, patientIdFromToken, event.getFhirResourceType()); + break; + case "Device": + case "Practitioner": + case "RelatedPerson": + String msg = "Compartment search for compartment type '" + compartment + "' is not permitted."; + throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); + default: + // If the operator has extended the server with custom compartment definitions, they will need to protect those separately + } - // Add the inclusion criteria parameter to the front of the search parameter list - searchContext.getSearchParameters().add(0, inclusionCriteria); + if (log.isLoggable(Level.FINE)) { + log.fine("Performing compartment search for compartment '" + compartment + "/" + compartmentId + "'."); + } + } else { + // Not compartment search - validate and convert to Patient compartment search if the resource type can be in the Patient compartment + try { + if (compartmentHelper.getCompartmentResourceTypes(PATIENT).contains(event.getFhirResourceType())) { + // Special case for the List resource because we want to enable searches for Formulary Coverage Plan lists + if ("List".equals(event.getFhirResourceType())) { + // In this case, we will rely on the afterSearch logic to prevent unauthorized access to patient-scoped Lists + return; } - } catch (Exception e) { - String msg = "Unexpected exception converting to Patient compartment search: " + e.getMessage(); - throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.EXCEPTION)); + + // Build the Patient compartment inclusion criteria search parameter + QueryParameter inclusionCriteria = searchHelper.buildInclusionCriteria(PATIENT, patientIdFromToken, event.getFhirResourceType()); + + // Add the inclusion criteria parameter to the front of the search parameter list + searchContext.getSearchParameters().add(0, inclusionCriteria); } + } catch (Exception e) { + String msg = "Unexpected exception converting to Patient compartment search: " + e.getMessage(); + throw new FHIRPersistenceInterceptorException(msg).withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.EXCEPTION)); } } } @@ -479,15 +534,20 @@ public void afterHistory(FHIRPersistenceEvent event) throws FHIRPersistenceInter private void enforceDirectProvenanceAccess(FHIRPersistenceEvent event, Resource resource, Set patientIdFromToken, Map> scopesFromToken) throws FHIRPersistenceInterceptorException { if (resource instanceof Provenance) { - if (!isAllowed(((Provenance) resource).getTarget(), event.getPersistenceImpl(), patientIdFromToken, Permission.READ, scopesFromToken)) { - String msg = Permission.READ + " permission to 'Provenance/" + resource.getId() + - "' with context id(s): " + patientIdFromToken + - " requires access to one or more of its target resources."; - if (log.isLoggable(Level.FINE)) { - log.fine(msg); + ContextType approvedContext = checkScopes("Provenance", Permission.READ, scopesFromToken); + // if the approving context is of type 'patient' then the Provenance should only be returned if the patient has access + // to the target of the Provenance + if (approvedContext == ContextType.PATIENT) { + if (!isAllowed(((Provenance) resource).getTarget(), event.getPersistenceImpl(), patientIdFromToken, Permission.READ, scopesFromToken)) { + String msg = Permission.READ + " permission to 'Provenance/" + resource.getId() + + "' with context id(s): " + patientIdFromToken + + " requires access to one or more of its target resources."; + if (log.isLoggable(Level.FINE)) { + log.fine(msg); + } + throw new FHIRPersistenceInterceptorException(msg) + .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); } - throw new FHIRPersistenceInterceptorException(msg) - .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); } } } @@ -646,6 +706,10 @@ private boolean isApprovedByScopes(String resourceType, Permission requiredPermi // "Resource" is used for "*" which applies to all resource types if (scope.getResourceType() == ResourceType.Value.RESOURCE || scope.getResourceType().value().equals(resourceType)) { if (hasPermission(scope.getPermission(), requiredPermission)) { + if (log.isLoggable(Level.FINE)) { + log.fine(requiredPermission.value() + " permission for '" + resourceType + + "' is granted via scope " + scope); + } return true; } } @@ -722,13 +786,14 @@ private boolean isAllowed(String resourceType, String resourceId, Resource resou Objects.requireNonNull(resourceId, "resourceId"); Objects.requireNonNull(contextIds, "contextIds"); + // For `system` and `user` scopes, we grant access to all resources of the requested type. + // Implementers that use these scopes are encouraged to layer on their own permissions model beyond this. for (ContextType context : List.of(ContextType.SYSTEM, ContextType.USER)) { - // For `system` and `user` scopes, we grant access to all resources of the requested type. - // Implementers that use these scopes are encouraged to layer on their own permissions model beyond this. if (!grantedScopes.containsKey(context)) { continue; } + // look for any scope that approves the current interaction Optional approvingScope = grantedScopes.get(context).stream() .filter(s -> s.getResourceType() == ResourceType.Value.RESOURCE || s.getResourceType().value().equals(resourceType)) .filter(s -> hasPermission(s.getPermission(), requiredPermission)) @@ -742,16 +807,24 @@ private boolean isAllowed(String resourceType, String resourceId, Resource resou } } + // For `patient` scopes, except for a couple special cases, we prevent access to resources that can be in a patient + // compartment unless those resources exist in one or more compartment of the patient ids passed via 'contextIds' if (grantedScopes.containsKey(ContextType.PATIENT)) { + if (contextIds.isEmpty()) { + String msg = "Access token is missing 'patient_id' claim"; + throw new FHIRPersistenceInterceptorException(msg) + .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); + } + + // look for any scope that approves the current interaction Optional approvingScope = grantedScopes.get(ContextType.PATIENT).stream() .filter(s -> s.getResourceType() == ResourceType.Value.RESOURCE || s.getResourceType().value().equals(resourceType)) .filter(s -> hasPermission(s.getPermission(), requiredPermission)) .findAny(); if (resource == null) { - // For `patient` scopes, if no resource was retrieved, which happens with history/search - // operations with an HTTP Prefer header with 'return=minimal', there is no way to check if the - // resource meets the criteria of being in the Patient compartment. + // If no resource was retrieved, which happens with history/search operations that have an HTTP Prefer header with 'return=minimal', + // there is no way to check if the resource meets the criteria of being in the Patient compartment. if (log.isLoggable(Level.FINE)) { log.fine("Unable to determine if " + requiredPermission.value() + " permission for '" + resourceType + "/" + resourceId + "' is granted via any patient scopes due to the resource being absent in the response"); @@ -765,7 +838,7 @@ private boolean isAllowed(String resourceType, String resourceId, Resource resou // 1. direct search // 2. _revinclude from a different resource type // For case 1, the search is already scoped to the patient compartment and therefore only approved resources should be included - // For case 2, only Provenance resources which target to another resource in the response bundle will be included and therefor we + // For case 2, only Provenance resources which target to another resource in the response bundle will be included and therefore we // can base the access decision on those resources rather than the Provenance // In the case of read/vread/history, access to Provenance will be handled elsewhere; @@ -781,7 +854,7 @@ private boolean isAllowed(String resourceType, String resourceId, Resource resou // Drug Formulary). // TODO: consider double-checking that the Patient compartment inclusion criteria for List have no - // patient references. The inclusion criteria are "subject" and "source". + // patient references. The inclusion criteria are "subject" and "source". if (resource.getMeta() != null && resource.getMeta().getProfile().contains(Canonical.of(DAVINCI_DRUG_FORMULARY_COVERAGE_PLAN))) { if (log.isLoggable(Level.FINE)) { @@ -933,12 +1006,16 @@ private Map> getScopesFromToken(DecodedJWT jwt) throws .collect(Collectors.groupingBy(s -> s.getContextType())); } + /** + * @return the set of Patient.id context values in the patient_id claim or an empty set if none exist + */ private Set getPatientIdFromToken(DecodedJWT jwt) throws FHIRPersistenceInterceptorException { Claim claim = jwt.getClaim("patient_id"); if (claim.isNull()) { - String msg = "Authorization token is missing 'patient_id' claim"; - throw new FHIRPersistenceInterceptorException(msg) - .withIssue(FHIRUtil.buildOperationOutcomeIssue(msg, IssueType.FORBIDDEN)); + if (log.isLoggable(Level.FINE)) { + log.fine("Authorization token is missing 'patient_id' claim"); + } + return Collections.emptySet(); } String patientId = claim.asString(); diff --git a/fhir-smart/src/test/java/com/ibm/fhir/smart/test/AuthzPolicyEnforcementTest.java b/fhir-smart/src/test/java/com/ibm/fhir/smart/test/AuthzPolicyEnforcementTest.java index 59b77eb62c6..aec1355505a 100644 --- a/fhir-smart/src/test/java/com/ibm/fhir/smart/test/AuthzPolicyEnforcementTest.java +++ b/fhir-smart/src/test/java/com/ibm/fhir/smart/test/AuthzPolicyEnforcementTest.java @@ -288,6 +288,95 @@ public void testDirectPatientInteraction() throws FHIRPersistenceInterceptorExce } } + @Test + public void testBeforeSearch_systemLevel() throws FHIRPersistenceInterceptorException { + FHIRSearchContextImpl searchContext = new FHIRSearchContextImpl(); + + // Valid system-level search + try { + FHIRRequestContext.get().setHttpHeaders(buildRequestHeaders("user/*.read", PATIENT_ID)); + + properties.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_TYPE, "Resource"); + properties.put(FHIRPersistenceEvent.PROPNAME_SEARCH_CONTEXT_IMPL, searchContext); + FHIRPersistenceEvent event = new FHIRPersistenceEvent(observation, properties); + interceptor.beforeSearch(event); + } catch (FHIRPersistenceInterceptorException e) { + fail("System search interaction was not allowed but should have been", e); + } + + // Invalid system-level search + try { + FHIRRequestContext.get().setHttpHeaders(buildRequestHeaders("patient/*.read", PATIENT_ID)); + FHIRSystemHistoryContext systemHistoryContext = new FHIRSystemHistoryContextImpl(); + + properties.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_TYPE, "Resource"); + properties.remove(FHIRPersistenceEvent.PROPNAME_RESOURCE_ID); + properties.put(FHIRPersistenceEvent.PROPNAME_SYSTEM_HISTORY_CONTEXT_IMPL, systemHistoryContext); + FHIRPersistenceEvent event = new FHIRPersistenceEvent(observation, properties); + interceptor.beforeSearch(event); + fail("System search interaction was allowed but should not be"); + } catch (FHIRPersistenceInterceptorException e) { + // success + assertEquals(1, e.getIssues().size()); + assertEquals(IssueType.FORBIDDEN, e.getIssues().get(0).getCode()); + assertEquals(e.getIssues().get(0).getDetails().getText().getValue(), + "Whole-system interactions require a user or system scope with a wildcard resource type: ('user'|'system') '/' '*' '.' ( 'read' | '*' )"); + } + + // Valid system-level search with types + try { + FHIRRequestContext.get().setHttpHeaders(buildRequestHeaders("system/*.read", PATIENT_ID)); + FHIRSystemHistoryContextImpl systemHistoryContext = new FHIRSystemHistoryContextImpl(); + systemHistoryContext.addResourceType("Patient"); + systemHistoryContext.addResourceType("Observation"); + + properties.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_TYPE, "Resource"); + properties.remove(FHIRPersistenceEvent.PROPNAME_RESOURCE_ID); + properties.put(FHIRPersistenceEvent.PROPNAME_SYSTEM_HISTORY_CONTEXT_IMPL, systemHistoryContext); + FHIRPersistenceEvent event = new FHIRPersistenceEvent(observation, properties); + interceptor.beforeSearch(event); + } catch (FHIRPersistenceInterceptorException e) { + fail("System search interaction was not allowed but should have been", e); + } + + // Valid system-level search with types + try { + FHIRRequestContext.get().setHttpHeaders(buildRequestHeaders("system/Patient.read system/Observation.read", PATIENT_ID)); + FHIRSystemHistoryContextImpl systemHistoryContext = new FHIRSystemHistoryContextImpl(); + systemHistoryContext.addResourceType("Patient"); + systemHistoryContext.addResourceType("Observation"); + + properties.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_TYPE, "Resource"); + properties.remove(FHIRPersistenceEvent.PROPNAME_RESOURCE_ID); + properties.put(FHIRPersistenceEvent.PROPNAME_SYSTEM_HISTORY_CONTEXT_IMPL, systemHistoryContext); + FHIRPersistenceEvent event = new FHIRPersistenceEvent(observation, properties); + interceptor.beforeSearch(event); + } catch (FHIRPersistenceInterceptorException e) { + fail("System search interaction was not allowed but should have been", e); + } + + // Invalid system-level search with types + try { + FHIRRequestContext.get().setHttpHeaders(buildRequestHeaders("patient/Patient.read patient/Observation.read", PATIENT_ID)); + FHIRSystemHistoryContextImpl systemHistoryContext = new FHIRSystemHistoryContextImpl(); + systemHistoryContext.addResourceType("Patient"); + systemHistoryContext.addResourceType("Observation"); + + properties.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_TYPE, "Resource"); + properties.remove(FHIRPersistenceEvent.PROPNAME_RESOURCE_ID); + properties.put(FHIRPersistenceEvent.PROPNAME_SYSTEM_HISTORY_CONTEXT_IMPL, systemHistoryContext); + FHIRPersistenceEvent event = new FHIRPersistenceEvent(observation, properties); + interceptor.beforeSearch(event); + fail("System search interaction was allowed but should not be"); + } catch (FHIRPersistenceInterceptorException e) { + // success + assertEquals(1, e.getIssues().size()); + assertEquals(IssueType.FORBIDDEN, e.getIssues().get(0).getCode()); + assertEquals(e.getIssues().get(0).getDetails().getText().getValue(), + "Read permission for system-level interaction with type 'Patient' is not granted by any of the provided scopes: [[patient/Patient.read, patient/Observation.read]]"); + } + } + @Test(dataProvider = "scopeStringForSearch") public void testBeforeSearch(String scopeString, List contextIds, Set implicitCompartmentScopeResourceTypes) throws FHIRPersistenceInterceptorException { @@ -710,7 +799,7 @@ public void testBeforeHistory() throws FHIRPersistenceInterceptorException { assertEquals(1, e.getIssues().size()); assertEquals(IssueType.FORBIDDEN, e.getIssues().get(0).getCode()); assertEquals(e.getIssues().get(0).getDetails().getText().getValue(), - "Read permission for system history of '[Resource]' is not granted by any of the provided scopes: [[patient/*.read]]"); + "Whole-system interactions require a user or system scope with a wildcard resource type: ('user'|'system') '/' '*' '.' ( 'read' | '*' )"); } // Valid system-level history with types @@ -763,7 +852,7 @@ public void testBeforeHistory() throws FHIRPersistenceInterceptorException { assertEquals(1, e.getIssues().size()); assertEquals(IssueType.FORBIDDEN, e.getIssues().get(0).getCode()); assertEquals(e.getIssues().get(0).getDetails().getText().getValue(), - "Read permission for system history of '[Patient, Observation]' is not granted by any of the provided scopes: [[patient/Patient.read, patient/Observation.read]]"); + "Read permission for system-level interaction with type 'Patient' is not granted by any of the provided scopes: [[patient/Patient.read, patient/Observation.read]]"); } // Valid type-level history @@ -810,7 +899,7 @@ public void testBeforeHistory() throws FHIRPersistenceInterceptorException { assertEquals(1, e.getIssues().size()); assertEquals(IssueType.FORBIDDEN, e.getIssues().get(0).getCode()); assertEquals(e.getIssues().get(0).getDetails().getText().getValue(), - "Read permission for system history of '[Observation]' is not granted by any of the provided scopes: [[patient/Observation.read]]"); + "Read permission for system-level interaction with type 'Observation' is not granted by any of the provided scopes: [[patient/Observation.read]]"); } } diff --git a/fhir-smart/src/test/java/com/ibm/fhir/smart/test/JWTTest.java b/fhir-smart/src/test/java/com/ibm/fhir/smart/test/JWTTest.java index 2a306dcf7cd..a83550d54c9 100644 --- a/fhir-smart/src/test/java/com/ibm/fhir/smart/test/JWTTest.java +++ b/fhir-smart/src/test/java/com/ibm/fhir/smart/test/JWTTest.java @@ -67,4 +67,14 @@ public void testJWTwithSig() throws NoSuchAlgorithmException { assertEquals(decodedJwt.getClaim("bogus").asString(), null); assertEquals(decodedJwt.getClaim("bogus").asList(), null); } + + /** + * Utility for printing a JWT to sysout + */ + public static void main(String[] args) { + System.out.println(com.auth0.jwt.JWT.create() + .withClaim("scope", "patient/*.read") + .withClaim("patient_id", "Patient1") + .sign(Algorithm.none())); + } }