From 29795bb58da6ee13f481738edbb164ea950b58ff Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Thu, 18 May 2023 12:53:50 +0530 Subject: [PATCH 01/10] Patient finder support for extracting multiple patient IDs in query params (#160) * patient finder in comma delimited params --- .../gateway/plugin/ListAccessChecker.java | 31 ++-- .../gateway/plugin/PatientAccessChecker.java | 20 +-- .../gateway/plugin/AccessCheckerTestBase.java | 11 ++ .../gateway/plugin/ListAccessCheckerTest.java | 114 +++++++++++++++ .../plugin/PatientAccessCheckerTest.java | 11 ++ ...e_transaction_delete_multiple_patient.json | 12 ++ ...n_get_non_patient_multiple_authorized.json | 12 ++ .../google/fhir/gateway/PatientFinderImp.java | 132 +++++++++++++----- .../gateway/interfaces/PatientFinder.java | 7 +- 9 files changed, 297 insertions(+), 53 deletions(-) create mode 100644 plugins/src/test/resources/bundle_transaction_delete_multiple_patient.json create mode 100644 plugins/src/test/resources/bundle_transaction_get_non_patient_multiple_authorized.json diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java index d42f3337..e8aa5d41 100644 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java @@ -109,7 +109,7 @@ private boolean listIncludesItems(String itemsParam) { // may want to revisit it in the future. // Note patientIds are expected NOT to include the `Patient/` prefix (pure IDs only). private boolean serverListIncludesAnyPatient(Set patientIds) { - if (patientIds == null) { + if (patientIds == null || patientIds.isEmpty()) { return false; } // TODO consider using the HAPI FHIR client instead; see: @@ -123,7 +123,7 @@ private boolean serverListIncludesAnyPatient(Set patientIds) { // Note patientIds are expected to include the `Patient/` prefix. // TODO fix the above inconsistency with `serverListIncludesAnyPatient`. private boolean serverListIncludesAllPatients(Set patientIds) { - if (patientIds == null) { + if (patientIds == null || patientIds.isEmpty()) { return false; } String patientParam = queryBuilder(patientIds, "item=", "&"); @@ -190,8 +190,10 @@ private AccessDecision processGet(RequestDetailsReader requestDetails) { } return NoOpAccessDecision.accessDenied(); } - String patientId = patientFinder.findPatientFromParams(requestDetails); - return new NoOpAccessDecision(serverListIncludesAnyPatient(Sets.newHashSet(patientId))); + Set patientIds = patientFinder.findPatientsFromParams(requestDetails); + Set patientQueries = Sets.newHashSet(); + patientIds.forEach(patientId -> patientQueries.add(String.format("Patient/%s", patientId))); + return new NoOpAccessDecision(serverListIncludesAllPatients(patientQueries)); } private AccessDecision processPost(RequestDetailsReader requestDetails) { @@ -238,8 +240,10 @@ private AccessDecision processDelete(RequestDetailsReader requestDetails) { // TODO(https://github.com/google/fhir-access-proxy/issues/63):Support direct resource deletion. // There should be a patient id in search params; the param name is based on the resource. - String patientId = patientFinder.findPatientFromParams(requestDetails); - return new NoOpAccessDecision(serverListIncludesAnyPatient(Sets.newHashSet(patientId))); + Set patientIds = patientFinder.findPatientsFromParams(requestDetails); + Set patientQueries = Sets.newHashSet(); + patientIds.forEach(patientId -> patientQueries.add(String.format("Patient/%s", patientId))); + return new NoOpAccessDecision(serverListIncludesAllPatients(patientQueries)); } private AccessDecision checkNonPatientAccessInUpdate( @@ -249,10 +253,10 @@ private AccessDecision checkNonPatientAccessInUpdate( "Expected either PATCH or PUT!"); // We do not allow direct resource PUT/PATCH, so Patient ID must be returned - String patientId = patientFinder.findPatientFromParams(requestDetails); + Set patientIds = patientFinder.findPatientsFromParams(requestDetails); Set patientQueries = Sets.newHashSet(); // Escaping is not needed here as the set elements will be escaped later. - patientQueries.add(String.format("Patient/%s", patientId)); + patientIds.forEach(patientId -> patientQueries.add(String.format("Patient/%s", patientId))); Set patientSet = Sets.newHashSet(); if (updateMethod == RequestTypeEnum.PATCH) { @@ -327,6 +331,7 @@ private BundlePatients createBundlePatients(RequestDetailsReader requestDetails) Set patientsToCreate = Sets.newHashSet(); Set patientsToUpdate = Sets.newHashSet(); + Set patientsToDelete = patientsInBundleUnfiltered.getDeletedPatients(); for (String patientId : patientsInBundleUnfiltered.getUpdatedPatients()) { if (!patientsExist(patientId)) { @@ -342,7 +347,8 @@ private BundlePatients createBundlePatients(RequestDetailsReader requestDetails) Set patientQueries = Sets.newHashSet(); for (Set patientRefSet : patientsInBundleUnfiltered.getReferencedPatients()) { - if (Collections.disjoint(patientRefSet, patientsToCreate)) { + if (Collections.disjoint(patientRefSet, patientsToCreate) + && Collections.disjoint(patientRefSet, patientsToDelete)) { String orQuery = queryBuilder(patientRefSet, "Patient/", ","); patientQueries.add(orQuery); } @@ -355,6 +361,13 @@ private BundlePatients createBundlePatients(RequestDetailsReader requestDetails) } } + if (!patientsToDelete.isEmpty()) { + for (String eachPatient : patientsToDelete) { + String andQuery = String.format("Patient/%s", eachPatient); + patientQueries.add(andQuery); + } + } + if (!patientQueries.isEmpty() && !serverListIncludesAllPatients(patientQueries)) { logger.error("Reference Patients not in List!"); return null; diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java index 9db77003..019b9f39 100644 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java @@ -119,17 +119,21 @@ private AccessDecision processPost(RequestDetailsReader requestDetails) { return processCreate(requestDetails); } + private Boolean validatePatientIds(Set patientIds) { + return patientIds.size() == 1 && authorizedPatientId.equals(patientIds.iterator().next()); + } + private AccessDecision processRead(RequestDetailsReader requestDetails) { - String patientId = patientFinder.findPatientFromParams(requestDetails); + Set patientIds = patientFinder.findPatientsFromParams(requestDetails); return new NoOpAccessDecision( - authorizedPatientId.equals(patientId) + validatePatientIds(patientIds) && smartScopeChecker.hasPermission(requestDetails.getResourceName(), Permission.READ)); } private AccessDecision processSearch(RequestDetailsReader requestDetails) { - String patientId = patientFinder.findPatientFromParams(requestDetails); + Set patientIds = patientFinder.findPatientsFromParams(requestDetails); return new NoOpAccessDecision( - authorizedPatientId.equals(patientId) + validatePatientIds(patientIds) && smartScopeChecker.hasPermission( requestDetails.getResourceName(), Permission.SEARCH)); } @@ -159,9 +163,9 @@ private AccessDecision processDelete(RequestDetailsReader requestDetails) { return NoOpAccessDecision.accessDenied(); } // TODO(https://github.com/google/fhir-access-proxy/issues/63):Support direct resource deletion. - String patientId = patientFinder.findPatientFromParams(requestDetails); + Set patientIds = patientFinder.findPatientsFromParams(requestDetails); return new NoOpAccessDecision( - authorizedPatientId.equals(patientId) + validatePatientIds(patientIds) && smartScopeChecker.hasPermission( requestDetails.getResourceName(), Permission.DELETE)); } @@ -169,8 +173,8 @@ private AccessDecision processDelete(RequestDetailsReader requestDetails) { private AccessDecision checkNonPatientAccessInUpdate( RequestDetailsReader requestDetails, RequestTypeEnum updateMethod) { // We do not allow direct resource PUT/PATCH, so Patient ID must be returned - String patientId = patientFinder.findPatientFromParams(requestDetails); - if (!patientId.equals(authorizedPatientId)) { + Set referencedPatientIds = patientFinder.findPatientsFromParams(requestDetails); + if (!validatePatientIds(referencedPatientIds)) { return NoOpAccessDecision.accessDenied(); } diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessCheckerTestBase.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessCheckerTestBase.java index e32b1775..e0b81b66 100644 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessCheckerTestBase.java +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessCheckerTestBase.java @@ -350,6 +350,17 @@ public void canAccessSearchChaining() { testInstance.checkAccess(requestMock).canAccess(); } + @Test + public void canAccessPatientWithIdSearch() { + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + Map params = Maps.newHashMap(); + params.put("_id", new String[] {PATIENT_AUTHORIZED}); + when(requestMock.getParameters()).thenReturn(params); + AccessChecker testInstance = getInstance(); + testInstance.checkAccess(requestMock).canAccess(); + } + @Test(expected = InvalidRequestException.class) public void canAccessSearchReverseChaining() { when(requestMock.getResourceName()).thenReturn("Observation"); diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/ListAccessCheckerTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/ListAccessCheckerTest.java index 24480da4..d788a580 100644 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/ListAccessCheckerTest.java +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/ListAccessCheckerTest.java @@ -23,6 +23,7 @@ import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.rest.api.RequestTypeEnum; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import com.google.common.collect.Maps; import com.google.common.io.Resources; import com.google.fhir.gateway.HttpFhirClient; import com.google.fhir.gateway.PatientFinderImp; @@ -139,6 +140,54 @@ public void canAccessPutExistingPatient() throws IOException { assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); } + @Test + public void canAccessPatientWithMultipleIdSearch() throws IOException { + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + Map params = Maps.newHashMap(); + params.put("_id", new String[] {PATIENT_AUTHORIZED + "," + PATIENT_IN_BUNDLE_1}); + when(requestMock.getParameters()).thenReturn(params); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_IN_BUNDLE_1, PATIENT_AUTHORIZED), + "bundle_list_patient_item.json"); + AccessChecker testInstance = getInstance(); + testInstance.checkAccess(requestMock).canAccess(); + } + + @Test + public void canAccessPatientWithMultipleIdSearchUnauthorized() throws IOException { + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + Map params = Maps.newHashMap(); + params.put("_id", new String[] {PATIENT_AUTHORIZED + "," + PATIENT_NON_AUTHORIZED}); + when(requestMock.getParameters()).thenReturn(params); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_NON_AUTHORIZED, PATIENT_AUTHORIZED), + "bundle_empty.json"); + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } + + @Test + public void canAccessGetObservations() throws IOException { + when(requestMock.getResourceName()).thenReturn("Observation"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + setUpPatientSearchMock(PATIENT_AUTHORIZED, "patient_id_search_single.json"); + setUpPatientSearchMock(PATIENT_IN_BUNDLE_1, "patient_id_search_single.json"); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_IN_BUNDLE_1, PATIENT_AUTHORIZED), + "bundle_list_patient_item.json"); + Map params = Maps.newHashMap(); + params.put("subject", new String[] {PATIENT_AUTHORIZED + "," + PATIENT_IN_BUNDLE_1}); + when(requestMock.getParameters()).thenReturn(params); + + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + @Test public void canAccessPutNewPatient() throws IOException { when(requestMock.getResourceName()).thenReturn("Patient"); @@ -164,6 +213,19 @@ public void canAccessBundleGetNonPatientUnAuthorized() throws IOException { assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); } + @Test + public void canAccessBundleGetNonPatientAuthorized() throws IOException { + setUpFhirBundle("bundle_transaction_get_non_patient_multiple_authorized.json"); + setUpPatientSearchMock(PATIENT_AUTHORIZED, "patient_id_search_single.json"); + setUpPatientSearchMock(PATIENT_IN_BUNDLE_1, "patient_id_search_single.json"); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_IN_BUNDLE_1, PATIENT_AUTHORIZED), + "bundle_list_patient_item.json"); + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + @Test(expected = InvalidRequestException.class) public void canAccessBundleGetPatientNonAuthorized() throws IOException { setUpFhirBundle("bundle_transaction_get_patient_unauthorized.json"); @@ -255,6 +317,20 @@ public void canAccessBundleDeletePatient() throws IOException { assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); } + @Test + public void canAccessBundleDeleteMultiplePatients() throws IOException { + // Query: POST / -d @bundle_transaction_delete_multiple_patient.json + setUpFhirBundle("bundle_transaction_delete_multiple_patient.json"); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_IN_BUNDLE_1, PATIENT_AUTHORIZED), + "bundle_list_patient_item.json"); + + AccessChecker testInstance = getInstance(); + + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + @Test public void canAccessPatchObservationUnauthorizedPatient() throws IOException { // Query: PATCH /Observation?subject=Patient/PATIENT_AUTHORIZED -d \ @@ -314,6 +390,44 @@ public void canAccessDeleteObservation() throws IOException { assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); } + @Test + public void canAccessDeleteObservationsForMultiplePatients() throws IOException { + when(requestMock.getResourceName()).thenReturn("Observation"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); + setUpPatientSearchMock(PATIENT_AUTHORIZED, "patient_id_search_single.json"); + setUpPatientSearchMock(PATIENT_IN_BUNDLE_1, "patient_id_search_single.json"); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_IN_BUNDLE_1, PATIENT_AUTHORIZED), + "bundle_list_patient_item.json"); + Map params = Maps.newHashMap(); + params.put("subject", new String[] {PATIENT_AUTHORIZED + "," + PATIENT_IN_BUNDLE_1}); + when(requestMock.getParameters()).thenReturn(params); + + AccessChecker testInstance = getInstance(); + + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void canAccessDeleteObservationsForMultiplePatientsUnauthorized() throws IOException { + when(requestMock.getResourceName()).thenReturn("Observation"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); + setUpPatientSearchMock(PATIENT_AUTHORIZED, "patient_id_search_single.json"); + setUpPatientSearchMock(PATIENT_NON_AUTHORIZED, "patient_id_search_single.json"); + setUpFhirListSearchMock( + String.format( + "item=Patient%%2F%s&item=Patient%%2F%s", PATIENT_NON_AUTHORIZED, PATIENT_AUTHORIZED), + "bundle_empty.json"); + Map params = Maps.newHashMap(); + params.put("subject", new String[] {PATIENT_NON_AUTHORIZED + "," + PATIENT_AUTHORIZED}); + when(requestMock.getParameters()).thenReturn(params); + + AccessChecker testInstance = getInstance(); + + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } + @Test public void canAccessDeleteObservationUnauthorized() throws IOException { when(requestMock.getResourceName()).thenReturn("Observation"); diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/PatientAccessCheckerTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/PatientAccessCheckerTest.java index 8215da6a..569b1c6a 100644 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/PatientAccessCheckerTest.java +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/PatientAccessCheckerTest.java @@ -216,4 +216,15 @@ public void canAccessBundleSearchResourcesNoValidPermission() throws IOException AccessChecker testInstance = getInstance(); assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); } + + @Test + public void canAccessGetObservationMultipleSubjectsUnauthorized() { + when(requestMock.getResourceName()).thenReturn("Observation"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + when(requestMock.getParameters()) + .thenReturn( + Map.of("subject", new String[] {PATIENT_AUTHORIZED + "," + PATIENT_NON_AUTHORIZED})); + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } } diff --git a/plugins/src/test/resources/bundle_transaction_delete_multiple_patient.json b/plugins/src/test/resources/bundle_transaction_delete_multiple_patient.json new file mode 100644 index 00000000..dcbcd349 --- /dev/null +++ b/plugins/src/test/resources/bundle_transaction_delete_multiple_patient.json @@ -0,0 +1,12 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "request": { + "method": "DELETE", + "url": "Patient?_id=be92a43f-de46-affa-b131-bbf9eea51140,420e791b-e419-c19b-3144-29e101c2c12f" + } + } + ] +} \ No newline at end of file diff --git a/plugins/src/test/resources/bundle_transaction_get_non_patient_multiple_authorized.json b/plugins/src/test/resources/bundle_transaction_get_non_patient_multiple_authorized.json new file mode 100644 index 00000000..52ee67a4 --- /dev/null +++ b/plugins/src/test/resources/bundle_transaction_get_non_patient_multiple_authorized.json @@ -0,0 +1,12 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "request": { + "method": "GET", + "url": "Encounter?patient=be92a43f-de46-affa-b131-bbf9eea51140,420e791b-e419-c19b-3144-29e101c2c12f" + } + } + ] +} \ No newline at end of file diff --git a/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java b/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java index 64423844..e8153752 100644 --- a/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java +++ b/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java @@ -44,8 +44,11 @@ import java.net.URL; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; @@ -74,6 +77,7 @@ public final class PatientFinderImp implements PatientFinder { private static final String PATCH_OP_ADD = "add"; private static final String PATCH_VALUE = "value"; private static final String PATCH_PATH = "path"; + private static final String RESOURCE_ID_FIELD = "_id"; private final IFhirPath fhirPath; private final Map> patientSearchParams; @@ -95,7 +99,7 @@ private PatientFinderImp( } @Nullable - private String checkParamsAndFindPatientId( + private ImmutableSet checkParamsAndFindPatientIds( String resourceName, Map queryParameters) { checkFhirJoinParams(queryParameters); List searchParams = patientSearchParams.get(resourceName); @@ -104,18 +108,33 @@ private String checkParamsAndFindPatientId( String[] paramValues = queryParameters.get(param); // We ignore if multiple search parameters match compartment definition. if (paramValues != null && paramValues.length == 1) { - // Making sure that we extract the actual ID without any resource type or URL. - IIdType id = new IdDt(paramValues[0]); - // TODO add test for null/non-Patient cases. - if (id.getResourceType() == null || id.getResourceType().equals("Patient")) { - return FhirUtil.checkIdOrFail(id.getIdPart()); - } + // Making sure we extract all patient IDs in case this is a comma delimited string + return getPatientIdsFromDelimitedString(paramValues[0]); } } } return null; } + private ImmutableSet getPatientIdsFromDelimitedString(String delimitedString) { + String[] patientResources = delimitedString.trim().split(","); + Set patients = + Arrays.stream(patientResources) + .map( + resource -> { + // Making sure that we extract the actual ID without any resource type or URL. + IIdType id = new IdDt(resource); + // TODO add test for null/non-Patient cases. + if (id.getResourceType() == null || id.getResourceType().equals("Patient")) { + return FhirUtil.checkIdOrFail(id.getIdPart()); + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return ImmutableSet.copyOf(patients); + } + private void checkFhirJoinParams(Map queryParams) { // TODO decide whether to expose `blockJoins` as a configuration parameter or not. If we want to // expose this and make it more customizable (e.g., what joins to accept and what to reject) or @@ -142,27 +161,40 @@ private void checkFhirJoinParams(Map queryParams) { } } - private String findPatientId(BundleEntryRequestComponent requestComponent) + private ImmutableSet findPatientIds(BundleEntryRequestComponent requestComponent) throws URISyntaxException { - String patientId = null; + ImmutableSet patientIds = null; if (requestComponent.getUrl() != null) { URI resourceUri = new URI(requestComponent.getUrl()); IIdType referenceElement = new Reference(resourceUri.getPath()).getReferenceElement(); if (FhirUtil.isSameResourceType(referenceElement.getResourceType(), ResourceType.Patient)) { - return FhirUtil.checkIdOrFail(referenceElement.getIdPart()); + String patientId = FhirUtil.checkIdOrFail(referenceElement.getIdPart()); + return ImmutableSet.of(patientId); } + + // Reference is not created for URLs without an ID as a path parameter, hence an explicit + // check on the path + if (Objects.equals(resourceUri.getPath().toLowerCase(), ResourceType.Patient.getPath())) { + Map queryParams = UrlUtil.parseQueryString(resourceUri.getQuery()); + String[] patientIdValues = queryParams.get(RESOURCE_ID_FIELD); + if (patientIdValues != null && patientIdValues.length == 1) { + // Making sure we extract all patient IDs in case this is a comma delimited string + return getPatientIdsFromDelimitedString(patientIdValues[0]); + } + } + if (referenceElement.getResourceType() == null) { Map queryParams = UrlUtil.parseQueryString(resourceUri.getQuery()); - patientId = checkParamsAndFindPatientId(resourceUri.getPath(), queryParams); + patientIds = checkParamsAndFindPatientIds(resourceUri.getPath(), queryParams); } } - if (patientId == null) { + if (patientIds == null || patientIds.isEmpty()) { ExceptionUtil.throwRuntimeExceptionAndLog( logger, - "Patient ID cannot be found in " + requestComponent.getUrl(), + "Patient IDs cannot be found in " + requestComponent.getUrl(), InvalidRequestException.class); } - return patientId; + return patientIds; } /** Checks if the request is for a Patient resource. */ @@ -171,13 +203,14 @@ private Boolean isPatientResourceType(BundleEntryRequestComponent requestCompone if (requestComponent.getUrl() != null) { URI resourceUri = new URI(requestComponent.getUrl()); IIdType referenceElement = new Reference(resourceUri.getPath()).getReferenceElement(); - return FhirUtil.isSameResourceType(referenceElement.getResourceType(), ResourceType.Patient); + return FhirUtil.isSameResourceType(referenceElement.getResourceType(), ResourceType.Patient) + || Objects.equals(resourceUri.getPath().toLowerCase(), ResourceType.Patient.getPath()); } return false; } @Override - public String findPatientFromParams(RequestDetailsReader requestDetails) { + public Set findPatientsFromParams(RequestDetailsReader requestDetails) { String resourceName = requestDetails.getResourceName(); if (resourceName == null) { ExceptionUtil.throwRuntimeExceptionAndLog( @@ -185,10 +218,9 @@ public String findPatientFromParams(RequestDetailsReader requestDetails) { "No resource specified for request " + requestDetails.getRequestPath(), InvalidRequestException.class); } - // Note we only let fetching data for one patient in each query; we may want to revisit this - // if we need to batch multiple patients together in one query. + if (FhirUtil.isSameResourceType(resourceName, ResourceType.Patient)) { - return FhirUtil.getIdOrNull(requestDetails); + return getPatientIdsFromPatientRequestUrl(requestDetails); } if (FhirUtil.getIdOrNull(requestDetails) != null) { // Block any direct, non-patient resource fetches (e.g. Encounter/EID). @@ -198,17 +230,33 @@ public String findPatientFromParams(RequestDetailsReader requestDetails) { logger, "Direct resource fetch is only supported for Patient; use search for " + resourceName, InvalidRequestException.class); - return null; } Map queryParams = requestDetails.getParameters(); - String patientId = checkParamsAndFindPatientId(resourceName, queryParams); - if (patientId == null) { + Set patientIds = checkParamsAndFindPatientIds(resourceName, queryParams); + if (patientIds == null || patientIds.isEmpty()) { ExceptionUtil.throwRuntimeExceptionAndLog( logger, "Patient ID cannot be found in " + requestDetails.getCompleteUrl(), InvalidRequestException.class); } - return patientId; + return patientIds; + } + + private Set getPatientIdsFromPatientRequestUrl(RequestDetailsReader requestDetails) { + // Note we only let fetching data for one patient in each query for a patient resource URL; we + // may want to revisit this + // if we need to batch multiple patients together in one query. + String patientId = FhirUtil.getIdOrNull(requestDetails); + if (patientId != null) { + return Set.of(patientId); + } + Map queryParams = requestDetails.getParameters(); + String[] patientIdValues = queryParams.get(RESOURCE_ID_FIELD); + if (patientIdValues != null && patientIdValues.length == 1) { + // Making sure we extract all patient IDs in case this is a comma delimited string + return getPatientIdsFromDelimitedString(patientIdValues[0]); + } + return Collections.emptySet(); } private JsonArray createJsonArrayFromRequest(RequestDetailsReader request) { @@ -360,21 +408,29 @@ private String parsePatchForPatientId(JsonObject patch, String resourceName) { private void processGet(BundleEntryComponent entryComponent, BundlePatientsBuilder builder) throws URISyntaxException { // Ignore body content and just look at request. - String patientId = findPatientId(entryComponent.getRequest()); - builder.addPatient(PatientOp.READ, patientId); + Set patientIds = findPatientIds(entryComponent.getRequest()); + patientIds.forEach(patientId -> builder.addPatient(PatientOp.READ, patientId)); } private void processPatch(BundleEntryComponent entryComponent, BundlePatientsBuilder builder) throws URISyntaxException { // Find patient id in request.url - String patientId = findPatientId(entryComponent.getRequest()); + ImmutableSet patientIds = findPatientIds(entryComponent.getRequest()); String resourceType = new Reference(entryComponent.getResource().getId()).getReferenceElement().getResourceType(); if (FhirUtil.isSameResourceType(resourceType, ResourceType.Patient)) { - builder.addPatient(PatientOp.UPDATE, patientId); + if (patientIds.size() > 1) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + String.format( + "Invalid Patch Request for Patient with multiple ids %s", + entryComponent.getRequest().getUrl()), + InvalidRequestException.class); + } + builder.addPatient(PatientOp.UPDATE, patientIds.iterator().next()); } else { - builder.addReferencedPatients(Sets.newHashSet(patientId)); + builder.addReferencedPatients(patientIds); } // Find patient ids in body if (!FhirUtil.isSameResourceType( @@ -402,11 +458,19 @@ private void processPatch(BundleEntryComponent entryComponent, BundlePatientsBui private void processPut(BundleEntryComponent entryComponent, BundlePatientsBuilder builder) throws URISyntaxException { Resource resource = entryComponent.getResource(); - String patientId = findPatientId(entryComponent.getRequest()); + Set patientIds = findPatientIds(entryComponent.getRequest()); if (FhirUtil.isSameResourceType(resource.fhirType(), ResourceType.Patient)) { - builder.addPatient(PatientOp.UPDATE, patientId); + if (patientIds.size() > 1) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + String.format( + "Invalid Put Request for Patient with multiple ids %s", + entryComponent.getRequest().getUrl()), + InvalidRequestException.class); + } + builder.addPatient(PatientOp.UPDATE, patientIds.iterator().next()); } else { - builder.addReferencedPatients(Sets.newHashSet(patientId)); + builder.addReferencedPatients(patientIds); addPatientReference(resource, builder); } } @@ -423,11 +487,11 @@ private void processPost(BundleEntryComponent entryComponent, BundlePatientsBuil private void processDelete(BundleEntryComponent entryComponent, BundlePatientsBuilder builder) throws URISyntaxException { // Ignore body content and just look at request. - String patientId = findPatientId(entryComponent.getRequest()); + Set patientIds = findPatientIds(entryComponent.getRequest()); if (isPatientResourceType(entryComponent.getRequest())) { - builder.addDeletedPatients(ImmutableSet.of(patientId)); + builder.addDeletedPatients(patientIds); } else { - builder.addReferencedPatients(ImmutableSet.of(patientId)); + builder.addReferencedPatients(patientIds); } } diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java b/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java index 701f483c..2d5f6953 100644 --- a/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java +++ b/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java @@ -19,6 +19,7 @@ import com.google.fhir.gateway.BundlePatients; import java.util.Set; import org.hl7.fhir.r4.model.Bundle; +import org.jetbrains.annotations.NotNull; public interface PatientFinder { /** @@ -26,11 +27,13 @@ public interface PatientFinder { * patient can be inferred from query parameters. * * @param requestDetails the request - * @return the id of the patient that this query belongs to or null if it cannot be inferred. + * @return the ids of the patients that this query belongs to or an empty set if it cannot be + * inferred. * @throws InvalidRequestException for various reasons when unexpected parameters or content are * encountered. Callers are expected to deny access when this happens. */ - String findPatientFromParams(RequestDetailsReader requestDetails); + @NotNull + Set findPatientsFromParams(RequestDetailsReader requestDetails); /** * Find all patients referenced or updated in a Bundle. From 3f3d31caec780f97dbf96c8104df66dbbb7250de Mon Sep 17 00:00:00 2001 From: williamito Date: Mon, 5 Jun 2023 15:38:52 -0700 Subject: [PATCH 02/10] Create codeql.yml (#168) Enable advanced CodeQL config with default settings. --- .github/workflows/codeql.yml | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..8706e02c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,77 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '25 21 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From 850f0ee82afff52102b4656d9ebe5f61eccec736 Mon Sep 17 00:00:00 2001 From: Bashir Sadjad Date: Wed, 14 Jun 2023 09:00:26 -0400 Subject: [PATCH 03/10] Update README.md Added an option to skip spotless in the suggested instructions. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 90ad6d36..0e4f03a2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ all pieces can be woven together into a single Spring Boot app. To build all modules, from the root run: ```shell -mvn package +mvn package -Dspotless.apply.skip=true ``` The server and the plugins can be run together through this executable jar ( From 72598a736397f66dcd67f83305d2ef388d34731c Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Mon, 10 Jul 2023 19:19:40 +0300 Subject: [PATCH 04/10] Support Request details in Access decision Post Processing (#174) --- .../fhir/gateway/plugin/AccessGrantedAndUpdateList.java | 3 ++- .../gateway/plugin/AccessGrantedAndUpdateListTest.java | 8 ++++++-- .../fhir/gateway/BearerAuthorizationInterceptor.java | 2 +- .../com/google/fhir/gateway/CapabilityPostProcessor.java | 3 ++- .../google/fhir/gateway/interfaces/AccessDecision.java | 3 ++- .../fhir/gateway/interfaces/NoOpAccessDecision.java | 2 +- .../fhir/gateway/BearerAuthorizationInterceptorTest.java | 3 ++- 7 files changed, 16 insertions(+), 8 deletions(-) diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java index fdd36907..77a830d1 100644 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java @@ -75,7 +75,8 @@ public boolean canAccess() { } @Override - public String postProcess(HttpResponse response) throws IOException { + public String postProcess(RequestDetailsReader requestDetailsReader, HttpResponse response) + throws IOException { Preconditions.checkState(HttpUtil.isResponseValid(response)); String content = CharStreams.toString(HttpUtil.readerFromEntity(response.getEntity())); IParser parser = fhirContext.newJsonParser(); diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java index e42498d0..9efc7ae0 100644 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java @@ -18,6 +18,7 @@ import ca.uhn.fhir.context.FhirContext; import com.google.common.io.Resources; import com.google.fhir.gateway.HttpFhirClient; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -39,6 +40,9 @@ public class AccessGrantedAndUpdateListTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private HttpResponse responseMock; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private RequestDetailsReader requestDetailsReader; + private static final FhirContext fhirContext = FhirContext.forR4(); private AccessGrantedAndUpdateList testInstance; @@ -55,7 +59,7 @@ public void postProcessNewPatientPut() throws IOException { testInstance = AccessGrantedAndUpdateList.forPatientResource( TEST_LIST_ID, httpFhirClientMock, fhirContext); - testInstance.postProcess(responseMock); + testInstance.postProcess(requestDetailsReader, responseMock); } @Test @@ -63,6 +67,6 @@ public void postProcessNewPatientPost() throws IOException { testInstance = AccessGrantedAndUpdateList.forPatientResource( TEST_LIST_ID, httpFhirClientMock, fhirContext); - testInstance.postProcess(responseMock); + testInstance.postProcess(requestDetailsReader, responseMock); } } diff --git a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java index 247c3037..d18496a6 100644 --- a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java +++ b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java @@ -288,7 +288,7 @@ public boolean authorizeRequest(RequestDetails requestDetails) { if (HttpUtil.isResponseValid(response)) { try { // For post-processing rationale/example see b/207589782#comment3. - content = outcome.postProcess(response); + content = outcome.postProcess(new RequestDetailsToReader(requestDetails), response); } catch (Exception e) { // Note this is after a successful fetch/update of the FHIR store. That success must be // passed to the client even if the access related post-processing fails. diff --git a/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java b/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java index 399fffe0..4afaf079 100644 --- a/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java +++ b/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java @@ -65,7 +65,8 @@ public boolean canAccess() { } @Override - public String postProcess(HttpResponse response) throws IOException { + public String postProcess(RequestDetailsReader requestDetailsReader, HttpResponse response) + throws IOException { Preconditions.checkState(HttpUtil.isResponseValid(response)); String content = CharStreams.toString(HttpUtil.readerFromEntity(response.getEntity())); IParser parser = fhirContext.newJsonParser(); diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java b/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java index edaf5ccc..48df4adb 100644 --- a/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java +++ b/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java @@ -48,10 +48,11 @@ public interface AccessDecision { *

An example of this is when a new patient is created as the result of the query and that * patient ID should be added to some access lists. * + * @param request the client to server request details * @param response the response returned from the FHIR store * @return the response entity content (with any post-processing modifications needed) if this * reads the response; otherwise null. Note that we should try to avoid reading the whole * content in memory whenever it is not needed for post-processing. */ - String postProcess(HttpResponse response) throws IOException; + String postProcess(RequestDetailsReader request, HttpResponse response) throws IOException; } diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java b/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java index d0394811..7921c983 100644 --- a/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java +++ b/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java @@ -36,7 +36,7 @@ public boolean canAccess() { } @Override - public String postProcess(HttpResponse response) { + public String postProcess(RequestDetailsReader requestDetailsReader, HttpResponse response) { return null; } diff --git a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java index 5e8d09f9..d2e5672a 100644 --- a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java +++ b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java @@ -379,7 +379,8 @@ public RequestMutation getRequestMutation(RequestDetailsReader requestDetailsRea return RequestMutation.builder().queryParams(paramMutations).build(); } - public String postProcess(HttpResponse response) throws IOException { + public String postProcess( + RequestDetailsReader requestDetailsReader, HttpResponse response) throws IOException { return null; } }; From c263b3945396f7f7e8cd315e28615ea965dd1a40 Mon Sep 17 00:00:00 2001 From: Bashir Sadjad Date: Fri, 18 Aug 2023 13:21:40 -0400 Subject: [PATCH 05/10] Refactorings for supporting custom endpoints with examples. (#182) --- e2e-test/clients.py | 2 +- exec/README.md | 12 ++ .../gateway/CustomFhirEndpointExample.java | 109 ++++++++++ .../gateway/CustomGenericEndpointExample.java | 39 ++++ .../java/com/google/fhir/gateway/MainApp.java | 4 + .../BearerAuthorizationInterceptor.java | 140 +------------ .../fhir/gateway/FhirClientFactory.java | 56 +++++ .../google/fhir/gateway/FhirProxyServer.java | 53 +---- .../fhir/gateway/GenericFhirClient.java | 5 +- .../google/fhir/gateway/HttpFhirClient.java | 3 + .../google/fhir/gateway/TokenVerifier.java | 191 ++++++++++++++++++ .../BearerAuthorizationInterceptorTest.java | 127 ++---------- .../fhir/gateway/GenericFhirClientTest.java | 13 +- .../fhir/gateway/TokenVerifierTest.java | 142 +++++++++++++ 14 files changed, 595 insertions(+), 301 deletions(-) create mode 100644 exec/README.md create mode 100644 exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java create mode 100644 exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java create mode 100644 server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java create mode 100644 server/src/main/java/com/google/fhir/gateway/TokenVerifier.java create mode 100644 server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java diff --git a/e2e-test/clients.py b/e2e-test/clients.py index a14ba01d..63b441eb 100644 --- a/e2e-test/clients.py +++ b/e2e-test/clients.py @@ -65,7 +65,7 @@ class FhirProxyClient: """ def __init__(self, host: str = "http://localhost", port: int = 8080) -> None: - self.base_url = "{}:{}".format(host, port) + self.base_url = "{}:{}/fhir".format(host, port) self.session = _setup_session(self.base_url) def get_resource_count( diff --git a/exec/README.md b/exec/README.md new file mode 100644 index 00000000..e0cab368 --- /dev/null +++ b/exec/README.md @@ -0,0 +1,12 @@ +# Sample application + +This module is to show simple examples of how to use the FHIR Gateway. The +minimal application is +[MainApp](src/main/java/com/google/fhir/gateway/MainApp.java). With this single +class, you can create an executable app with the Gateway [server](../server) and +all of the `AccessChecker` [plugins](../plugins), namely +[ListAccessChecker](../plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java) +and +[PatientAccessChecker](../plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java). + +Two other classes are provided to show how to implement custom endpoints. diff --git a/exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java b/exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java new file mode 100644 index 00000000..89a826d5 --- /dev/null +++ b/exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java @@ -0,0 +1,109 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.parser.IParser; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.ListResource; +import org.hl7.fhir.r4.model.ListResource.ListEntryComponent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This is an example servlet that requires a valid JWT to be present as the Bearer Authorization + * header. Although it is not a standard FHIR query, but it uses the FHIR server to construct the + * response. In this example, it inspects the JWT and depending on its claims, constructs the list + * of Patient IDs that the user has access to. + * + *

The two types of tokens resemble {@link com.google.fhir.gateway.plugin.ListAccessChecker} and + * {@link com.google.fhir.gateway.plugin.PatientAccessChecker} expected tokens. But those are just + * picked as examples and this custom endpoint is independent of any {@link + * com.google.fhir.gateway.interfaces.AccessChecker}. + */ +@WebServlet("/myPatients") +public class CustomFhirEndpointExample extends HttpServlet { + + private static final Logger logger = LoggerFactory.getLogger(CustomFhirEndpointExample.class); + private final TokenVerifier tokenVerifier; + + private final HttpFhirClient fhirClient; + + public CustomFhirEndpointExample() throws IOException { + this.tokenVerifier = TokenVerifier.createFromEnvVars(); + this.fhirClient = FhirClientFactory.createFhirClientFromEnvVars(); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + // Check the Bearer token to be a valid JWT with required claims. + String authHeader = request.getHeader("Authorization"); + if (authHeader == null) { + throw new ServletException("No Authorization header provided!"); + } + List patientIds = new ArrayList<>(); + // Note for a more meaningful HTTP status code, we can catch AuthenticationException in: + DecodedJWT jwt = tokenVerifier.decodeAndVerifyBearerToken(authHeader); + Claim claim = jwt.getClaim("patient_list"); + if (claim.asString() != null) { + logger.info("Found a 'patient_list' claim: {}", claim); + String listUri = "List/" + claim.asString(); + HttpResponse fhirResponse = fhirClient.getResource(listUri); + HttpUtil.validateResponseOrFail(fhirResponse, listUri); + if (fhirResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { + logger.error("Error while fetching {}", listUri); + response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); + return; + } + FhirContext fhirContext = FhirContext.forCached(FhirVersionEnum.R4); + IParser jsonParser = fhirContext.newJsonParser(); + IBaseResource resource = jsonParser.parseResource(fhirResponse.getEntity().getContent()); + ListResource listResource = (ListResource) resource; + for (ListEntryComponent entry : listResource.getEntry()) { + patientIds.add(entry.getItem().getReference()); + } + } else { + claim = jwt.getClaim("patient_id"); + if (claim.asString() != null) { + logger.info("Found a 'patient_id' claim: {}", claim); + patientIds.add(claim.asString()); + } + } + if (claim.asString() == null) { + String error = "Found no patient claim in the token!"; + logger.error(error); + response.getOutputStream().print(error); + response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); + return; + } + response.getOutputStream().print("Your patient are: " + String.join(" ", patientIds)); + response.setStatus(HttpStatus.SC_OK); + } +} diff --git a/exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java b/exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java new file mode 100644 index 00000000..a8a34954 --- /dev/null +++ b/exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import java.io.IOException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.HttpStatus; + +/** + * This is an example servlet that can be used for any custom endpoint. It does not make any + * assumptions about authorization headers or accessing a FHIR server. + */ +@WebServlet("/custom/*") +public class CustomGenericEndpointExample extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse resp) throws IOException { + String uri = request.getRequestURI(); + // For a real production case, `uri` needs to be escaped. + resp.getOutputStream().print("Successful request to the custom endpoint " + uri); + resp.setStatus(HttpStatus.SC_OK); + } +} diff --git a/exec/src/main/java/com/google/fhir/gateway/MainApp.java b/exec/src/main/java/com/google/fhir/gateway/MainApp.java index 815e3976..bd2b9bff 100644 --- a/exec/src/main/java/com/google/fhir/gateway/MainApp.java +++ b/exec/src/main/java/com/google/fhir/gateway/MainApp.java @@ -19,6 +19,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.ServletComponentScan; +/** + * This class shows the minimum that is required to create a FHIR Gateway with all AccessChecker + * plugins defined in "com.google.fhir.gateway.plugin". + */ @SpringBootApplication(scanBasePackages = {"com.google.fhir.gateway.plugin"}) @ServletComponentScan(basePackages = "com.google.fhir.gateway") public class MainApp { diff --git a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java index d18496a6..f971ec55 100644 --- a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java +++ b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java @@ -26,13 +26,7 @@ import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.exceptions.JWTDecodeException; -import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; -import com.auth0.jwt.interfaces.Verification; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.fhir.gateway.interfaces.AccessChecker; @@ -40,27 +34,14 @@ import com.google.fhir.gateway.interfaces.AccessDecision; import com.google.fhir.gateway.interfaces.RequestDetailsReader; import com.google.fhir.gateway.interfaces.RequestMutation; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import java.io.IOException; import java.io.Reader; import java.io.StringReader; import java.io.Writer; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.EncodedKeySpec; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; -import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.CollectionUtils; @@ -72,7 +53,6 @@ public class BearerAuthorizationInterceptor { LoggerFactory.getLogger(BearerAuthorizationInterceptor.class); private static final String DEFAULT_CONTENT_TYPE = "application/json; charset=UTF-8"; - private static final String BEARER_PREFIX = "Bearer "; private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding"; @@ -84,25 +64,16 @@ public class BearerAuthorizationInterceptor { // For fetching CapabilityStatement: https://www.hl7.org/fhir/http.html#capabilities @VisibleForTesting static final String METADATA_PATH = "metadata"; - // TODO: Make this configurable or based on the given JWT; we should at least support some other - // RSA* and ES* algorithms (requires ECDSA512 JWT algorithm). - private static final String SIGN_ALGORITHM = "RS256"; - - private final String tokenIssuer; - private final Verification jwtVerifierConfig; - private final HttpUtil httpUtil; + private final TokenVerifier tokenVerifier; private final RestfulServer server; private final HttpFhirClient fhirClient; private final AccessCheckerFactory accessFactory; private final AllowedQueriesChecker allowedQueriesChecker; - private final String configJson; BearerAuthorizationInterceptor( HttpFhirClient fhirClient, - String tokenIssuer, - String wellKnownEndpoint, + TokenVerifier tokenVerifier, RestfulServer server, - HttpUtil httpUtil, AccessCheckerFactory accessFactory, AllowedQueriesChecker allowedQueriesChecker) throws IOException { @@ -110,113 +81,12 @@ public class BearerAuthorizationInterceptor { Preconditions.checkNotNull(server); this.server = server; this.fhirClient = fhirClient; - this.httpUtil = httpUtil; - this.tokenIssuer = tokenIssuer; + this.tokenVerifier = tokenVerifier; this.accessFactory = accessFactory; this.allowedQueriesChecker = allowedQueriesChecker; - RSAPublicKey issuerPublicKey = fetchAndDecodePublicKey(); - jwtVerifierConfig = JWT.require(Algorithm.RSA256(issuerPublicKey, null)); - this.configJson = httpUtil.fetchWellKnownConfig(tokenIssuer, wellKnownEndpoint); logger.info("Created proxy to the FHIR store " + this.fhirClient.getBaseUrl()); } - private RSAPublicKey fetchAndDecodePublicKey() throws IOException { - // Preconditions.checkState(SIGN_ALGORITHM.equals("ES512")); - Preconditions.checkState(SIGN_ALGORITHM.equals("RS256")); - // final String keyAlgorithm = "EC"; - final String keyAlgorithm = "RSA"; - try { - // TODO: Make sure this works for any issuer not just Keycloak; instead of this we should - // read the metadata and choose the right endpoint for the keys. - HttpResponse response = httpUtil.getResourceOrFail(new URI(tokenIssuer)); - JsonObject jsonObject = - JsonParser.parseString(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8)) - .getAsJsonObject(); - String keyStr = jsonObject.get("public_key").getAsString(); - if (keyStr == null) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Cannot find 'public_key' in issuer metadata."); - } - KeyFactory keyFactory = KeyFactory.getInstance(keyAlgorithm); - EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(keyStr)); - return (RSAPublicKey) keyFactory.generatePublic(keySpec); - } catch (URISyntaxException e) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Error in token issuer URI " + tokenIssuer, e, AuthenticationException.class); - } catch (NoSuchAlgorithmException e) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Invalid algorithm " + keyAlgorithm, e, AuthenticationException.class); - } catch (InvalidKeySpecException e) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Invalid KeySpec: " + e.getMessage(), e, AuthenticationException.class); - } - // We should never get here, this is to keep the IDE happy! - return null; - } - - private JWTVerifier buildJwtVerifier(String issuer) { - - if (tokenIssuer.equals(issuer)) { - return jwtVerifierConfig.withIssuer(tokenIssuer).build(); - } else if (FhirProxyServer.isDevMode()) { - // If server is in DEV mode, set issuer to one from request - logger.warn("Server run in DEV mode. Setting issuer to issuer from request."); - return jwtVerifierConfig.withIssuer(issuer).build(); - } else { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, - String.format("The token issuer %s does not match the expected token issuer", issuer), - AuthenticationException.class); - return null; - } - } - - @VisibleForTesting - DecodedJWT decodeAndVerifyBearerToken(String authHeader) { - if (!authHeader.startsWith(BEARER_PREFIX)) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, - "Authorization header is not a valid Bearer token!", - AuthenticationException.class); - } - String bearerToken = authHeader.substring(BEARER_PREFIX.length()); - DecodedJWT jwt = null; - try { - jwt = JWT.decode(bearerToken); - } catch (JWTDecodeException e) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Failed to decode JWT: " + e.getMessage(), e, AuthenticationException.class); - } - String issuer = jwt.getIssuer(); - String algorithm = jwt.getAlgorithm(); - JWTVerifier jwtVerifier = buildJwtVerifier(issuer); - logger.info( - String.format( - "JWT issuer is %s, audience is %s, and algorithm is %s", - issuer, jwt.getAudience(), algorithm)); - - if (!SIGN_ALGORITHM.equals(algorithm)) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, - String.format( - "Only %s signing algorithm is supported, got %s", SIGN_ALGORITHM, algorithm), - AuthenticationException.class); - } - DecodedJWT verifiedJwt = null; - try { - verifiedJwt = jwtVerifier.verify(jwt); - } catch (JWTVerificationException e) { - // Throwing an AuthenticationException instead since it is handled by HAPI and a 401 - // status code is returned in the response. - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, - String.format("JWT verification failed with error: %s", e.getMessage()), - e, - AuthenticationException.class); - } - return verifiedJwt; - } - private AccessDecision checkAuthorization(RequestDetails requestDetails) { if (METADATA_PATH.equals(requestDetails.getRequestPath())) { // No further check is required; provide CapabilityStatement with security information. @@ -236,7 +106,7 @@ private AccessDecision checkAuthorization(RequestDetails requestDetails) { ExceptionUtil.throwRuntimeExceptionAndLog( logger, "No Authorization header provided!", AuthenticationException.class); } - DecodedJWT decodedJwt = decodeAndVerifyBearerToken(authHeader); + DecodedJWT decodedJwt = tokenVerifier.decodeAndVerifyBearerToken(authHeader); FhirContext fhirContext = server.getFhirContext(); AccessDecision allowedQueriesDecision = allowedQueriesChecker.checkAccess(requestDetailsReader); if (allowedQueriesDecision.canAccess()) { @@ -399,7 +269,7 @@ private void serveWellKnown(ServletRequestDetails request) { DEFAULT_CONTENT_TYPE, Constants.CHARSET_NAME_UTF8, false); - writer.write(configJson); + writer.write(tokenVerifier.getWellKnownConfig()); writer.close(); } catch (IOException e) { logger.error( diff --git a/server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java b/server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java new file mode 100644 index 00000000..45a98891 --- /dev/null +++ b/server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder; +import java.io.IOException; + +/** + * This is a helper class to create appropriate FHIR clients to talk to the configured FHIR server. + */ +public class FhirClientFactory { + private static final String PROXY_TO_ENV = "PROXY_TO"; + private static final String BACKEND_TYPE_ENV = "BACKEND_TYPE"; + + public static HttpFhirClient createFhirClientFromEnvVars() throws IOException { + String backendType = System.getenv(BACKEND_TYPE_ENV); + if (backendType == null) { + throw new IllegalArgumentException( + String.format("The environment variable %s is not set!", BACKEND_TYPE_ENV)); + } + String fhirStore = System.getenv(PROXY_TO_ENV); + if (fhirStore == null) { + throw new IllegalArgumentException( + String.format("The environment variable %s is not set!", PROXY_TO_ENV)); + } + return chooseHttpFhirClient(backendType, fhirStore); + } + + private static HttpFhirClient chooseHttpFhirClient(String backendType, String fhirStore) + throws IOException { + // TODO add an enum if the list of special FHIR servers grow and rename HAPI to GENERIC. + if (backendType.equals("GCP")) { + return new GcpFhirClient(fhirStore, GcpFhirClient.createCredentials()); + } + + if (backendType.equals("HAPI")) { + return new GenericFhirClientBuilder().setFhirStore(fhirStore).build(); + } + throw new IllegalArgumentException( + String.format( + "The environment variable %s is not set to either GCP or HAPI!", BACKEND_TYPE_ENV)); + } +} diff --git a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java index ec180b63..bf44d67d 100644 --- a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java +++ b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java @@ -19,7 +19,6 @@ import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.interceptor.CorsInterceptor; -import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder; import com.google.fhir.gateway.interfaces.AccessCheckerFactory; import java.io.IOException; import java.util.ArrayList; @@ -32,18 +31,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.cors.CorsConfiguration; -@WebServlet("/*") +@WebServlet("/fhir/*") public class FhirProxyServer extends RestfulServer { private static final Logger logger = LoggerFactory.getLogger(FhirProxyServer.class); - private static final String PROXY_TO_ENV = "PROXY_TO"; - private static final String TOKEN_ISSUER_ENV = "TOKEN_ISSUER"; private static final String ACCESS_CHECKER_ENV = "ACCESS_CHECKER"; private static final String PERMISSIVE_ACCESS_CHECKER = "permissive"; - private static final String BACKEND_TYPE_ENV = "BACKEND_TYPE"; - private static final String WELL_KNOWN_ENDPOINT_ENV = "WELL_KNOWN_ENDPOINT"; - private static final String WELL_KNOWN_ENDPOINT_DEFAULT = ".well-known/openid-configuration"; private static final String ALLOWED_QUERIES_FILE_ENV = "ALLOWED_QUERIES_FILE"; // TODO: improve this mixture of Spring based IOC with non-@Component classes. This is the @@ -61,30 +55,6 @@ static boolean isDevMode() { // implement a way to kill the server immediately when initialize fails. @Override protected void initialize() throws ServletException { - String backendType = System.getenv(BACKEND_TYPE_ENV); - if (backendType == null) { - throw new ServletException( - String.format("The environment variable %s is not set!", BACKEND_TYPE_ENV)); - } - String fhirStore = System.getenv(PROXY_TO_ENV); - if (fhirStore == null) { - throw new ServletException( - String.format("The environment variable %s is not set!", PROXY_TO_ENV)); - } - String tokenIssuer = System.getenv(TOKEN_ISSUER_ENV); - if (tokenIssuer == null) { - throw new ServletException( - String.format("The environment variable %s is not set!", TOKEN_ISSUER_ENV)); - } - - String wellKnownEndpoint = System.getenv(WELL_KNOWN_ENDPOINT_ENV); - if (wellKnownEndpoint == null) { - wellKnownEndpoint = WELL_KNOWN_ENDPOINT_DEFAULT; - logger.info( - String.format( - "The environment variable %s is not set! Using default value of %s instead ", - WELL_KNOWN_ENDPOINT_ENV, WELL_KNOWN_ENDPOINT_DEFAULT)); - } // TODO make the FHIR version configurable. // Create a context for the appropriate version setFhirContext(FhirContext.forR4()); @@ -95,14 +65,13 @@ protected void initialize() throws ServletException { try { logger.info("Adding BearerAuthorizationInterceptor "); AccessCheckerFactory checkerFactory = chooseAccessCheckerFactory(); - HttpFhirClient httpFhirClient = chooseHttpFhirClient(backendType, fhirStore); + HttpFhirClient httpFhirClient = FhirClientFactory.createFhirClientFromEnvVars(); + TokenVerifier tokenVerifier = TokenVerifier.createFromEnvVars(); registerInterceptor( new BearerAuthorizationInterceptor( httpFhirClient, - tokenIssuer, - wellKnownEndpoint, + tokenVerifier, this, - new HttpUtil(), checkerFactory, new AllowedQueriesChecker(System.getenv(ALLOWED_QUERIES_FILE_ENV)))); } catch (IOException e) { @@ -110,20 +79,6 @@ protected void initialize() throws ServletException { } } - private HttpFhirClient chooseHttpFhirClient(String backendType, String fhirStore) - throws ServletException, IOException { - if (backendType.equals("GCP")) { - return new GcpFhirClient(fhirStore, GcpFhirClient.createCredentials()); - } - - if (backendType.equals("HAPI")) { - return new GenericFhirClientBuilder().setFhirStore(fhirStore).build(); - } - throw new ServletException( - String.format( - "The environment variable %s is not set to either GCP or HAPI!", BACKEND_TYPE_ENV)); - } - private AccessCheckerFactory chooseAccessCheckerFactory() { logger.info( String.format( diff --git a/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java b/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java index 1765d48a..ee9071d7 100644 --- a/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java +++ b/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java @@ -17,7 +17,6 @@ import java.net.URI; import java.net.URISyntaxException; -import javax.servlet.ServletException; import org.apache.http.Header; import org.apache.http.client.utils.URIBuilder; import org.apache.http.message.BasicHeader; @@ -61,9 +60,9 @@ public GenericFhirClientBuilder setFhirStore(String fhirStore) { return this; } - public GenericFhirClient build() throws ServletException { + public GenericFhirClient build() { if (fhirStore == null || fhirStore.isBlank()) { - throw new ServletException("FhirStore not set!"); + throw new IllegalArgumentException("FhirStore not set!"); } return new GenericFhirClient(fhirStore); } diff --git a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java index 0570bdf8..d9e46167 100644 --- a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java +++ b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java @@ -37,6 +37,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +// TODO evaluate if we can provide the API of HAPI's IGenericClient as well: +// https://hapifhir.io/hapi-fhir/docs/client/generic_client.html public abstract class HttpFhirClient { private static final Logger logger = LoggerFactory.getLogger(HttpFhirClient.class); @@ -102,6 +104,7 @@ private void setUri(RequestBuilder builder, String resourcePath) { } } + /** This method is intended to be used only for requests that are relayed to the FHIR store. */ HttpResponse handleRequest(ServletRequestDetails request) throws IOException { String httpMethod = request.getServletRequest().getMethod(); RequestBuilder builder = RequestBuilder.create(httpMethod); diff --git a/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java b/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java new file mode 100644 index 00000000..c88e6a30 --- /dev/null +++ b/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java @@ -0,0 +1,191 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.Verification; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.EncodedKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import org.apache.http.HttpResponse; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TokenVerifier { + + private static final Logger logger = LoggerFactory.getLogger(TokenVerifier.class); + private static final String TOKEN_ISSUER_ENV = "TOKEN_ISSUER"; + private static final String WELL_KNOWN_ENDPOINT_ENV = "WELL_KNOWN_ENDPOINT"; + private static final String WELL_KNOWN_ENDPOINT_DEFAULT = ".well-known/openid-configuration"; + private static final String BEARER_PREFIX = "Bearer "; + + // TODO: Make this configurable or based on the given JWT; we should at least support some other + // RSA* and ES* algorithms (requires ECDSA512 JWT algorithm). + private static final String SIGN_ALGORITHM = "RS256"; + + private final String tokenIssuer; + private final Verification jwtVerifierConfig; + private final HttpUtil httpUtil; + private final String configJson; + + @VisibleForTesting + TokenVerifier(String tokenIssuer, String wellKnownEndpoint, HttpUtil httpUtil) + throws IOException { + this.tokenIssuer = tokenIssuer; + this.httpUtil = httpUtil; + RSAPublicKey issuerPublicKey = fetchAndDecodePublicKey(); + jwtVerifierConfig = JWT.require(Algorithm.RSA256(issuerPublicKey, null)); + this.configJson = httpUtil.fetchWellKnownConfig(tokenIssuer, wellKnownEndpoint); + } + + public static TokenVerifier createFromEnvVars() throws IOException { + String tokenIssuer = System.getenv(TOKEN_ISSUER_ENV); + if (tokenIssuer == null) { + throw new IllegalArgumentException( + String.format("The environment variable %s is not set!", TOKEN_ISSUER_ENV)); + } + + String wellKnownEndpoint = System.getenv(WELL_KNOWN_ENDPOINT_ENV); + if (wellKnownEndpoint == null) { + wellKnownEndpoint = WELL_KNOWN_ENDPOINT_DEFAULT; + logger.info( + String.format( + "The environment variable %s is not set! Using default value of %s instead ", + WELL_KNOWN_ENDPOINT_ENV, WELL_KNOWN_ENDPOINT_DEFAULT)); + } + return new TokenVerifier(tokenIssuer, wellKnownEndpoint, new HttpUtil()); + } + + public String getWellKnownConfig() { + return configJson; + } + + private RSAPublicKey fetchAndDecodePublicKey() throws IOException { + // Preconditions.checkState(SIGN_ALGORITHM.equals("ES512")); + Preconditions.checkState(SIGN_ALGORITHM.equals("RS256")); + // final String keyAlgorithm = "EC"; + final String keyAlgorithm = "RSA"; + try { + // TODO: Make sure this works for any issuer not just Keycloak; instead of this we should + // read the metadata and choose the right endpoint for the keys. + HttpResponse response = httpUtil.getResourceOrFail(new URI(tokenIssuer)); + JsonObject jsonObject = + JsonParser.parseString(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8)) + .getAsJsonObject(); + String keyStr = jsonObject.get("public_key").getAsString(); + if (keyStr == null) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Cannot find 'public_key' in issuer metadata."); + } + KeyFactory keyFactory = KeyFactory.getInstance(keyAlgorithm); + EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(keyStr)); + return (RSAPublicKey) keyFactory.generatePublic(keySpec); + } catch (URISyntaxException e) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Error in token issuer URI " + tokenIssuer, e, AuthenticationException.class); + } catch (NoSuchAlgorithmException e) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Invalid algorithm " + keyAlgorithm, e, AuthenticationException.class); + } catch (InvalidKeySpecException e) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Invalid KeySpec: " + e.getMessage(), e, AuthenticationException.class); + } + // We should never get here, this is to keep the IDE happy! + return null; + } + + private JWTVerifier buildJwtVerifier(String issuer) { + + if (tokenIssuer.equals(issuer)) { + return jwtVerifierConfig.withIssuer(tokenIssuer).build(); + } else if (FhirProxyServer.isDevMode()) { + // If server is in DEV mode, set issuer to one from request + logger.warn("Server run in DEV mode. Setting issuer to issuer from request."); + return jwtVerifierConfig.withIssuer(issuer).build(); + } else { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + String.format("The token issuer %s does not match the expected token issuer", issuer), + AuthenticationException.class); + return null; + } + } + + @VisibleForTesting + DecodedJWT decodeAndVerifyBearerToken(String authHeader) { + if (!authHeader.startsWith(BEARER_PREFIX)) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + "Authorization header is not a valid Bearer token!", + AuthenticationException.class); + } + String bearerToken = authHeader.substring(BEARER_PREFIX.length()); + DecodedJWT jwt = null; + try { + jwt = JWT.decode(bearerToken); + } catch (JWTDecodeException e) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Failed to decode JWT: " + e.getMessage(), e, AuthenticationException.class); + } + String issuer = jwt.getIssuer(); + String algorithm = jwt.getAlgorithm(); + JWTVerifier jwtVerifier = buildJwtVerifier(issuer); + logger.info( + String.format( + "JWT issuer is %s, audience is %s, and algorithm is %s", + issuer, jwt.getAudience(), algorithm)); + + if (!SIGN_ALGORITHM.equals(algorithm)) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + String.format( + "Only %s signing algorithm is supported, got %s", SIGN_ALGORITHM, algorithm), + AuthenticationException.class); + } + DecodedJWT verifiedJwt = null; + try { + verifiedJwt = jwtVerifier.verify(jwt); + } catch (JWTVerificationException e) { + // Throwing an AuthenticationException instead since it is handled by HAPI and a 401 + // status code is returned in the response. + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + String.format("JWT verification failed with error: %s", e.getMessage()), + e, + AuthenticationException.class); + } + return verifiedJwt; + } +} diff --git a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java index d2e5672a..466ef318 100644 --- a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java +++ b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java @@ -29,14 +29,9 @@ import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.rest.api.server.IRestfulResponse; import ca.uhn.fhir.rest.server.RestfulServer; -import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.rest.server.servlet.ServletRestfulResponse; -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTCreator; -import com.auth0.jwt.algorithms.Algorithm; -import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.io.Resources; @@ -49,16 +44,8 @@ import java.io.IOException; import java.io.StringWriter; import java.io.Writer; -import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.Key; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -89,19 +76,15 @@ public class BearerAuthorizationInterceptorTest { private BearerAuthorizationInterceptor testInstance; - private static final String BASE_URL = "http://myprxy/fhir"; + private static final String BASE_URL = "http://myproxy/fhir"; private static final String FHIR_STORE = "https://healthcare.googleapis.com/v1/projects/fhir-sdk/locations/us/datasets/" + "synthea-sample-data/fhirStores/gcs-data/fhir"; - private static final String TOKEN_ISSUER = "https://token.issuer"; - - private KeyPair keyPair; - @Mock private HttpFhirClient fhirClientMock; @Mock private RestfulServer serverMock; - @Mock private HttpUtil httpUtilMock; + @Mock private TokenVerifier tokenVerifierMock; @Mock private ServletRequestDetails requestMock; @@ -110,29 +93,12 @@ public class BearerAuthorizationInterceptorTest { private final Writer writerStub = new StringWriter(); - private String generateKeyPairAndEncode() { - try { - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(1024); - keyPair = generator.generateKeyPair(); - Key publicKey = keyPair.getPublic(); - Preconditions.checkState("X.509".equals(publicKey.getFormat())); - return Base64.getEncoder().encodeToString(publicKey.getEncoded()); - } catch (GeneralSecurityException e) { - logger.error("error in generating keys", e); - Preconditions.checkState(false); // We should never get here! - } - return null; - } - private BearerAuthorizationInterceptor createTestInstance( boolean isAccessGranted, String allowedQueriesConfig) throws IOException { return new BearerAuthorizationInterceptor( fhirClientMock, - TOKEN_ISSUER, - "test", + tokenVerifierMock, serverMock, - httpUtilMock, (jwt, httpFhirClient, fhirContext, patientFinder) -> new AccessChecker() { @Override @@ -145,75 +111,21 @@ public AccessDecision checkAccess(RequestDetailsReader requestDetails) { @Before public void setUp() throws IOException { - String publicKeyBase64 = generateKeyPairAndEncode(); - HttpResponse responseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); when(serverMock.getServerBaseForRequest(any(ServletRequestDetails.class))).thenReturn(BASE_URL); when(serverMock.getFhirContext()).thenReturn(fhirContext); - when(httpUtilMock.getResourceOrFail(any(URI.class))).thenReturn(responseMock); - TestUtil.setUpFhirResponseMock( - responseMock, String.format("{public_key: '%s'}", publicKeyBase64)); - URL idpUrl = Resources.getResource("idp_keycloak_config.json"); - String testIdpConfig = Resources.toString(idpUrl, StandardCharsets.UTF_8); - when(httpUtilMock.fetchWellKnownConfig(anyString(), anyString())).thenReturn(testIdpConfig); when(fhirClientMock.handleRequest(requestMock)).thenReturn(fhirResponseMock); when(fhirClientMock.getBaseUrl()).thenReturn(FHIR_STORE); testInstance = createTestInstance(true, null); } - private String signJwt(JWTCreator.Builder jwtBuilder) { - Algorithm algorithm = - Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate()); - String token = jwtBuilder.sign(algorithm); - logger.debug(String.format(" The generated JWT is: %s", token)); - return token; - } - - @Test - public void decodeAndVerifyBearerTokenTest() { - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder)); - } - - @Test(expected = AuthenticationException.class) - public void decodeAndVerifyBearerTokenWrongIssuer() { - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER + "WRONG"); - testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder)); - } - - @Test(expected = AuthenticationException.class) - public void decodeAndVerifyBearerTokenBadSignature() { - // We overwrite the original `keyPair` hence the signature won't match the original public key. - generateKeyPairAndEncode(); - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder)); - } - - @Test(expected = AuthenticationException.class) - public void decodeAndVerifyBearerTokenNoBearer() { - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - testInstance.decodeAndVerifyBearerToken(signJwt(jwtBuilder)); - } - - @Test(expected = AuthenticationException.class) - public void decodeAndVerifyBearerTokenMalformedBearer() { - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - testInstance.decodeAndVerifyBearerToken("BearerTTT " + signJwt(jwtBuilder)); - } - - @Test(expected = AuthenticationException.class) - public void decodeAndVerifyBearerTokenMalformedToken() { - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - testInstance.decodeAndVerifyBearerToken("Bearer TTT"); + private void setupBearerAndFhirResponse(String fhirStoreResponse) throws IOException { + setupFhirResponse(fhirStoreResponse, true); } - private void authorizeRequestCommonSetUp(String fhirStoreResponse) throws IOException { - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - String jwt = signJwt(jwtBuilder); - when(requestMock.getHeader("Authorization")).thenReturn("Bearer " + jwt); - setupFhirResponse(fhirStoreResponse); - } - - private void setupFhirResponse(String fhirStoreResponse) throws IOException { + private void setupFhirResponse(String fhirStoreResponse, boolean addBearer) throws IOException { + if (addBearer) { + when(requestMock.getHeader("Authorization")).thenReturn("Bearer ANYTHING"); + } IRestfulResponse proxyResponseMock = Mockito.mock(IRestfulResponse.class); when(requestMock.getResponse()).thenReturn(proxyResponseMock); when(proxyResponseMock.getResponseWriter( @@ -226,7 +138,7 @@ private void setupFhirResponse(String fhirStoreResponse) throws IOException { public void authorizeRequestPatient() throws IOException { URL patientUrl = Resources.getResource("test_patient.json"); String testPatientJson = Resources.toString(patientUrl, StandardCharsets.UTF_8); - authorizeRequestCommonSetUp(testPatientJson); + setupBearerAndFhirResponse(testPatientJson); testInstance.authorizeRequest(requestMock); assertThat(testPatientJson, equalTo(writerStub.toString())); } @@ -235,7 +147,7 @@ public void authorizeRequestPatient() throws IOException { public void authorizeRequestList() throws IOException { URL patientUrl = Resources.getResource("patient-list-example.json"); String testListJson = Resources.toString(patientUrl, StandardCharsets.UTF_8); - authorizeRequestCommonSetUp(testListJson); + setupBearerAndFhirResponse(testListJson); testInstance.authorizeRequest(requestMock); assertThat(testListJson, equalTo(writerStub.toString())); } @@ -244,7 +156,7 @@ public void authorizeRequestList() throws IOException { public void authorizeRequestTestReplaceUrl() throws IOException { URL searchUrl = Resources.getResource("patient_id_search.json"); String testPatientIdSearch = Resources.toString(searchUrl, StandardCharsets.UTF_8); - authorizeRequestCommonSetUp(testPatientIdSearch); + setupBearerAndFhirResponse(testPatientIdSearch); testInstance.authorizeRequest(requestMock); String replaced = testPatientIdSearch.replaceAll(FHIR_STORE, BASE_URL); assertThat(replaced, equalTo(writerStub.toString())); @@ -254,7 +166,7 @@ public void authorizeRequestTestReplaceUrl() throws IOException { public void authorizeRequestTestResourceErrorResponse() throws IOException { URL errorUrl = Resources.getResource("error_operation_outcome.json"); String errorResponse = Resources.toString(errorUrl, StandardCharsets.UTF_8); - authorizeRequestCommonSetUp(errorResponse); + setupBearerAndFhirResponse(errorResponse); when(fhirResponseMock.getStatusLine().getStatusCode()) .thenReturn(HttpStatus.SC_INTERNAL_SERVER_ERROR); testInstance.authorizeRequest(requestMock); @@ -277,6 +189,10 @@ public void authorizeRequestWellKnown() throws IOException { HttpServletRequest servletRequestMock = Mockito.mock(HttpServletRequest.class); when(requestMock.getServletRequest()).thenReturn(servletRequestMock); when(servletRequestMock.getProtocol()).thenReturn("HTTP/1.1"); + URL idpUrl = Resources.getResource("idp_keycloak_config.json"); + String testIdpConfig = Resources.toString(idpUrl, StandardCharsets.UTF_8); + when(tokenVerifierMock.getWellKnownConfig()).thenReturn(testIdpConfig); + testInstance.authorizeRequest(requestMock); Gson gson = new Gson(); Map jsonMap = Maps.newHashMap(); @@ -321,7 +237,7 @@ public void authorizeRequestMetadata() throws IOException { noAuthRequestSetup(BearerAuthorizationInterceptor.METADATA_PATH); URL capabilityUrl = Resources.getResource("capability.json"); String capabilityJson = Resources.toString(capabilityUrl, StandardCharsets.UTF_8); - authorizeRequestCommonSetUp(capabilityJson); + setupBearerAndFhirResponse(capabilityJson); testInstance.authorizeRequest(requestMock); IParser parser = fhirContext.newJsonParser(); IBaseResource resource = parser.parseResource(writerStub.toString()); @@ -340,7 +256,7 @@ public void authorizeAllowedUnauthenticatedRequest() throws IOException { createTestInstance( false, Resources.getResource("allowed_unauthenticated_queries.json").getPath()); String responseJson = "{\"resourceType\": \"Bundle\"}"; - setupFhirResponse(responseJson); + setupFhirResponse(responseJson, false); when(requestMock.getRequestPath()).thenReturn("Composition"); testInstance.authorizeRequest(requestMock); @@ -354,7 +270,7 @@ public void deniedRequest() throws IOException { testInstance = createTestInstance( false, Resources.getResource("allowed_unauthenticated_queries.json").getPath()); - authorizeRequestCommonSetUp("never returned response"); + setupBearerAndFhirResponse("never returned response"); when(requestMock.getRequestPath()).thenReturn("Patient"); testInstance.authorizeRequest(requestMock); @@ -400,8 +316,7 @@ public String postProcess( public void shouldSendGzippedResponseWhenRequested() throws IOException { testInstance = createTestInstance(true, null); String responseJson = "{\"resourceType\": \"Bundle\"}"; - JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); - when(requestMock.getHeader("Authorization")).thenReturn("Bearer " + signJwt(jwtBuilder)); + when(requestMock.getHeader("Authorization")).thenReturn("Bearer ANYTHING"); when(requestMock.getHeader("Accept-Encoding".toLowerCase())).thenReturn("gzip"); // requestMock.getResponse() {@link ServletRequestDetails#getResponse()} is an abstraction HAPI diff --git a/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java b/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java index 6391a8c5..e1e456f6 100644 --- a/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java +++ b/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java @@ -21,7 +21,6 @@ import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder; import java.net.URI; import java.net.URISyntaxException; -import javax.servlet.ServletException; import org.apache.http.Header; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,18 +29,18 @@ @RunWith(MockitoJUnitRunner.class) public class GenericFhirClientTest { - @Test(expected = ServletException.class) - public void buildGenericFhirClientFhirStoreNotSetTest() throws ServletException { + @Test(expected = IllegalArgumentException.class) + public void buildGenericFhirClientFhirStoreNotSetTest() { new GenericFhirClientBuilder().build(); } - @Test(expected = ServletException.class) - public void buildGenericFhirClientNoFhirStoreBlankTest() throws ServletException { + @Test(expected = IllegalArgumentException.class) + public void buildGenericFhirClientNoFhirStoreBlankTest() { new GenericFhirClientBuilder().setFhirStore(" ").build(); } @Test - public void getAuthHeaderNoUsernamePasswordTest() throws ServletException { + public void getAuthHeaderNoUsernamePasswordTest() { GenericFhirClient genericFhirClient = new GenericFhirClientBuilder().setFhirStore("random.fhir").build(); Header header = genericFhirClient.getAuthHeader(); @@ -50,7 +49,7 @@ public void getAuthHeaderNoUsernamePasswordTest() throws ServletException { } @Test - public void getUriForResourceTest() throws URISyntaxException, ServletException { + public void getUriForResourceTest() throws URISyntaxException { GenericFhirClient genericFhirClient = new GenericFhirClientBuilder().setFhirStore("random.fhir").build(); URI uri = genericFhirClient.getUriForResource("hello/world"); diff --git a/server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java b/server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java new file mode 100644 index 00000000..6a014616 --- /dev/null +++ b/server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.google.common.base.Preconditions; +import com.google.common.io.Resources; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; +import org.apache.http.HttpResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@RunWith(MockitoJUnitRunner.class) +public class TokenVerifierTest { + + private static final Logger logger = LoggerFactory.getLogger(TokenVerifierTest.class); + private static final String TOKEN_ISSUER = "https://token.issuer"; + + @Mock private HttpUtil httpUtilMock; + private KeyPair keyPair; + private String testIdpConfig; + private TokenVerifier testInstance; + + private String generateKeyPairAndEncode() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + keyPair = generator.generateKeyPair(); + Key publicKey = keyPair.getPublic(); + Preconditions.checkState("X.509".equals(publicKey.getFormat())); + return Base64.getEncoder().encodeToString(publicKey.getEncoded()); + } catch (GeneralSecurityException e) { + logger.error("error in generating keys", e); + Preconditions.checkState(false); // We should never get here! + } + return null; + } + + @Before + public void setUp() throws IOException { + String publicKeyBase64 = generateKeyPairAndEncode(); + HttpResponse responseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); + when(httpUtilMock.getResourceOrFail(any(URI.class))).thenReturn(responseMock); + TestUtil.setUpFhirResponseMock( + responseMock, String.format("{public_key: '%s'}", publicKeyBase64)); + URL idpUrl = Resources.getResource("idp_keycloak_config.json"); + testIdpConfig = Resources.toString(idpUrl, StandardCharsets.UTF_8); + when(httpUtilMock.fetchWellKnownConfig(anyString(), anyString())).thenReturn(testIdpConfig); + testInstance = new TokenVerifier(TOKEN_ISSUER, "test", httpUtilMock); + } + + private String signJwt(JWTCreator.Builder jwtBuilder) { + Algorithm algorithm = + Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate()); + String token = jwtBuilder.sign(algorithm); + logger.debug(String.format(" The generated JWT is: %s", token)); + return token; + } + + @Test + public void decodeAndVerifyBearerTokenTest() { + JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); + testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder)); + } + + @Test(expected = AuthenticationException.class) + public void decodeAndVerifyBearerTokenWrongIssuer() { + JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER + "WRONG"); + testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder)); + } + + @Test(expected = AuthenticationException.class) + public void decodeAndVerifyBearerTokenBadSignature() { + // We overwrite the original `keyPair` hence the signature won't match the original public key. + generateKeyPairAndEncode(); + JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); + testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder)); + } + + @Test(expected = AuthenticationException.class) + public void decodeAndVerifyBearerTokenNoBearer() { + JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); + testInstance.decodeAndVerifyBearerToken(signJwt(jwtBuilder)); + } + + @Test(expected = AuthenticationException.class) + public void decodeAndVerifyBearerTokenMalformedBearer() { + JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); + testInstance.decodeAndVerifyBearerToken("BearerTTT " + signJwt(jwtBuilder)); + } + + @Test(expected = AuthenticationException.class) + public void decodeAndVerifyBearerTokenMalformedToken() { + JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER); + testInstance.decodeAndVerifyBearerToken("Bearer TTT"); + } + + @Test + public void getWellKnownConfigTest() { + String config = testInstance.getWellKnownConfig(); + assertThat(config, equalTo(testIdpConfig)); + } +} From 4392c87cde108b0a9ca7569576aa5b55aa847d3b Mon Sep 17 00:00:00 2001 From: Bashir Sadjad Date: Thu, 31 Aug 2023 14:17:54 -0400 Subject: [PATCH 06/10] Fixed thread-safety and memory issues around JWT verifiers (#185) --- .../google/fhir/gateway/TokenVerifier.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java b/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java index c88e6a30..5837174f 100644 --- a/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java +++ b/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java @@ -38,6 +38,8 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; +import java.util.HashMap; +import java.util.Map; import org.apache.http.HttpResponse; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; @@ -56,7 +58,12 @@ public class TokenVerifier { private static final String SIGN_ALGORITHM = "RS256"; private final String tokenIssuer; + // Note the Verification class is _not_ thread-safe but the JWTVerifier instances created by its + // `build()` are thread-safe and reusable. It is important to reuse those instances, otherwise + // we may end up with a memory leak; details: https://github.com/auth0/java-jwt/issues/592 + // Access to `jwtVerifierConfig` and `verifierForIssuer` should be non-concurrent. private final Verification jwtVerifierConfig; + private final Map verifierForIssuer; private final HttpUtil httpUtil; private final String configJson; @@ -68,6 +75,7 @@ public class TokenVerifier { RSAPublicKey issuerPublicKey = fetchAndDecodePublicKey(); jwtVerifierConfig = JWT.require(Algorithm.RSA256(issuerPublicKey, null)); this.configJson = httpUtil.fetchWellKnownConfig(tokenIssuer, wellKnownEndpoint); + this.verifierForIssuer = new HashMap<>(); } public static TokenVerifier createFromEnvVars() throws IOException { @@ -126,21 +134,23 @@ private RSAPublicKey fetchAndDecodePublicKey() throws IOException { return null; } - private JWTVerifier buildJwtVerifier(String issuer) { - - if (tokenIssuer.equals(issuer)) { - return jwtVerifierConfig.withIssuer(tokenIssuer).build(); - } else if (FhirProxyServer.isDevMode()) { - // If server is in DEV mode, set issuer to one from request - logger.warn("Server run in DEV mode. Setting issuer to issuer from request."); - return jwtVerifierConfig.withIssuer(issuer).build(); - } else { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, - String.format("The token issuer %s does not match the expected token issuer", issuer), - AuthenticationException.class); - return null; + private synchronized JWTVerifier getJwtVerifier(String issuer) { + if (!tokenIssuer.equals(issuer)) { + if (FhirProxyServer.isDevMode()) { + // If server is in DEV mode, set issuer to one from request + logger.warn("Server run in DEV mode. Setting issuer to issuer from request."); + } else { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, + String.format("The token issuer %s does not match the expected token issuer", issuer), + AuthenticationException.class); + return null; + } + } + if (!verifierForIssuer.containsKey(issuer)) { + verifierForIssuer.put(issuer, jwtVerifierConfig.withIssuer(issuer).build()); } + return verifierForIssuer.get(issuer); } @VisibleForTesting @@ -161,7 +171,7 @@ DecodedJWT decodeAndVerifyBearerToken(String authHeader) { } String issuer = jwt.getIssuer(); String algorithm = jwt.getAlgorithm(); - JWTVerifier jwtVerifier = buildJwtVerifier(issuer); + JWTVerifier jwtVerifier = getJwtVerifier(issuer); logger.info( String.format( "JWT issuer is %s, audience is %s, and algorithm is %s", From c98e315cef65446661615f291e180cbe483a3d27 Mon Sep 17 00:00:00 2001 From: Benjamin Mwalimu Date: Tue, 3 Oct 2023 21:54:27 +0300 Subject: [PATCH 07/10] ONA Gateway <> Upstream Gateway Merge attempt (#179) --- Dockerfile | 10 +++---- README.md | 2 +- build.sh | 2 +- doc/design.md | 30 +++++++++---------- docker/hapi-proxy-compose.yaml | 2 +- e2e-test/e2e.sh | 2 +- exec/pom.xml | 3 ++ plugins/pom.xml | 0 pom.xml | 0 server/pom.xml | 0 .../fhir/gateway/AllowedQueriesChecker.java | 0 .../fhir/gateway/AllowedQueriesConfig.java | 0 .../BearerAuthorizationInterceptor.java | 0 .../google/fhir/gateway/ExceptionUtil.java | 2 +- .../google/fhir/gateway/FhirProxyServer.java | 5 +++- .../google/fhir/gateway/ProxyConstants.java | 2 +- .../gateway/AllowedQueriesCheckerTest.java | 0 .../BearerAuthorizationInterceptorTest.java | 5 ---- 18 files changed, 33 insertions(+), 32 deletions(-) mode change 100644 => 100755 Dockerfile mode change 100644 => 100755 exec/pom.xml mode change 100644 => 100755 plugins/pom.xml mode change 100644 => 100755 pom.xml mode change 100644 => 100755 server/pom.xml mode change 100644 => 100755 server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java mode change 100644 => 100755 server/src/main/java/com/google/fhir/gateway/AllowedQueriesConfig.java mode change 100644 => 100755 server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java mode change 100644 => 100755 server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 69c3e3df..26746167 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,8 @@ # # Image for building and running tests against the source code of -# the FHIR Access Proxy. -FROM maven:3.8.5-openjdk-11 as build +# the FHIR Gateway. +FROM maven:3.8.5-openjdk-11-slim as build RUN apt-get update && apt-get install -y nodejs npm RUN npm cache clean -f && npm install -g n && n stable @@ -37,10 +37,10 @@ RUN mvn spotless:check RUN mvn --batch-mode package -Pstandalone-app -Dlicense.skip=true -# Image for FHIR Access Proxy binary with configuration knobs as environment vars. +# Image for FHIR Gateway binary with configuration knobs as environment vars. FROM eclipse-temurin:11-jdk-focal as main -COPY --from=build /app/exec/target/exec-0.2.1-SNAPSHOT.jar / +COPY --from=build /app/exec/target/fhir-gateway-exec.jar / COPY resources/hapi_page_url_allowed_queries.json resources/hapi_page_url_allowed_queries.json ENV PROXY_PORT=8080 @@ -54,4 +54,4 @@ ENV BACKEND_TYPE="HAPI" ENV ACCESS_CHECKER="list" ENV RUN_MODE="PROD" -ENTRYPOINT java -jar exec-0.2.1-SNAPSHOT.jar --server.port=${PROXY_PORT} +ENTRYPOINT java -jar fhir-gateway-exec.jar --server.port=${PROXY_PORT} diff --git a/README.md b/README.md index 0e4f03a2..162cc118 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The proxy is also available as a [docker image](Dockerfile): ```shell $ docker run -p 8081:8080 -e TOKEN_ISSUER=[token_issuer_url] \ -e PROXY_TO=[fhir_server_url] -e ACCESS_CHECKER=list \ - us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:latest + us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:latest ``` Note if the `TOKEN_ISSUER` is on the `localhost` you may need to bypass proxy's diff --git a/build.sh b/build.sh index 8b3bbc3b..268c10b8 100755 --- a/build.sh +++ b/build.sh @@ -28,4 +28,4 @@ set -e export BUILD_ID=${KOKORO_BUILD_ID:-local} gcloud auth configure-docker us-docker.pkg.dev ./e2e-test/e2e.sh -docker push us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:${BUILD_ID} +docker push us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:${BUILD_ID} diff --git a/doc/design.md b/doc/design.md index 04cfe6d6..0f9a9b5f 100644 --- a/doc/design.md +++ b/doc/design.md @@ -320,35 +320,35 @@ varies by context. Each of these approaches are described in the following sections. In each case, we briefly describe what is supported in the first release of the access gateway. The "first release" is when we open-sourced the project in June 2022 in -[this GitHub repository](https://github.com/google/fhir-access-proxy). Let's +[this GitHub repository](https://github.com/google/fhir-gateway). Let's first look at the architecture of the gateway. There are two main components: -**[Server](https://github.com/google/fhir-access-proxy/tree/main/server/src/main/java/com/google/fhir/gateway)**: +**[Server](https://github.com/google/fhir-gateway/tree/main/server/src/main/java/com/google/fhir/gateway)**: The core of the access gateway is the "server" which provides a -[servlet](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java) +[servlet](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java) that processes FHIR queries and an -[authorization interceptor](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java) +[authorization interceptor](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java) that inspects those. The interceptor decodes and validates the JWT access-token and makes a call to an -[AccessChecker](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java) +[AccessChecker](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java) plugin to decide whether access should be granted or not. The server also provides common FHIR query/resource processing, e.g., -[PatientFinder](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java) +[PatientFinder](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java) for finding patient context. These libraries are meant to be used in the plugin implementations. -**[AccessChecker plugin](https://github.com/google/fhir-access-proxy/tree/main/plugins)**: +**[AccessChecker plugin](https://github.com/google/fhir-gateway/tree/main/plugins)**: Each access gateway needs at least one AccessChecker plugin. Gateway implementers can provide their customized access-check logic in this plugin. The server code's initialization finds plugins by looking for -[AccessCheckerFactory](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessCheckerFactory.java) +[AccessCheckerFactory](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessCheckerFactory.java) implementations that are [@Named](https://docs.oracle.com/javaee/7/api/javax/inject/Named.html). The specified name is used to select that plugin at runtime. Example implementations are -[ListAccessChecker](https://github.com/google/fhir-access-proxy/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java) +[ListAccessChecker](https://github.com/google/fhir-gateway/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java) and -[PatientAccessChecker](https://github.com/google/fhir-access-proxy/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java). +[PatientAccessChecker](https://github.com/google/fhir-gateway/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java). AccessChecker plugins can send RPCs to other backends if they need to collect extra information. In our examples, the plugins consult with the same FHIR store that resources are pulled from, but you could imagine consulting more hardened @@ -374,7 +374,7 @@ This approach helps support both the **flexible-access-control** and **untrusted-app** items from the [constraints](#scenarios-and-constraints) section. Note to use this approach for access-control, the patient context should be inferred from the FHIR query. The server provides -[a library](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java) +[a library](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java) for doing this. ### Query templates allowed/blocked list @@ -394,7 +394,7 @@ search results of a previous query. Just from these queries, we cannot decide what the patient context is, so we should let those queries go through (there is a security risk here but since `_getpages` param values are ephemeral UUIDs, this is probably ok). Here is a -[sample config](https://github.com/google/fhir-access-proxy/blob/main/resources/hapi_page_url_allowed_queries.json) +[sample config](https://github.com/google/fhir-gateway/blob/main/resources/hapi_page_url_allowed_queries.json) for this. We note that we want our core "server" to be _stateless_ (for easy scalability); therefore cannot store next/prev URLs from previous query results. @@ -424,11 +424,11 @@ structure of FHIR queries that the gateway accepts). So we still need some restrictions on the permitted queries as mentioned above. Among gateway interfaces, there is -[AccessDecision](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java) +[AccessDecision](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java) which is returned from a -[checkAccess](https://github.com/google/fhir-access-proxy/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java#L31). +[checkAccess](https://github.com/google/fhir-gateway/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java#L31). This interface has a -[postProcess](https://github.com/google/fhir-access-proxy/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java#L39) +[postProcess](https://github.com/google/fhir-gateway/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java#L39) method which can be used for post-processing of resources returned from the FHIR server. diff --git a/docker/hapi-proxy-compose.yaml b/docker/hapi-proxy-compose.yaml index bbcbf53e..9e511c38 100644 --- a/docker/hapi-proxy-compose.yaml +++ b/docker/hapi-proxy-compose.yaml @@ -35,7 +35,7 @@ version: "3.0" services: fhir-proxy: - image: us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:${BUILD_ID:-latest} + image: us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:${BUILD_ID:-latest} environment: - TOKEN_ISSUER - PROXY_TO diff --git a/e2e-test/e2e.sh b/e2e-test/e2e.sh index 5f4d1b66..724b6a28 100755 --- a/e2e-test/e2e.sh +++ b/e2e-test/e2e.sh @@ -21,7 +21,7 @@ set -e export BUILD_ID=${KOKORO_BUILD_ID:-local} function setup() { - docker build -t us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:${BUILD_ID} . + docker build -t us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:${BUILD_ID} . docker-compose -f docker/keycloak/config-compose.yaml \ up --force-recreate --remove-orphans -d --quiet-pull # TODO find a way to expose docker container logs in the output; currently diff --git a/exec/pom.xml b/exec/pom.xml old mode 100644 new mode 100755 index 472f74d0..a5ae4600 --- a/exec/pom.xml +++ b/exec/pom.xml @@ -79,6 +79,9 @@ + + ${project.parent.artifactId}-${project.artifactId} + diff --git a/plugins/pom.xml b/plugins/pom.xml old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml old mode 100644 new mode 100755 diff --git a/server/pom.xml b/server/pom.xml old mode 100644 new mode 100755 diff --git a/server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java b/server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java old mode 100644 new mode 100755 diff --git a/server/src/main/java/com/google/fhir/gateway/AllowedQueriesConfig.java b/server/src/main/java/com/google/fhir/gateway/AllowedQueriesConfig.java old mode 100644 new mode 100755 diff --git a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java old mode 100644 new mode 100755 diff --git a/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java b/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java index 90778d97..5edb826e 100644 --- a/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java +++ b/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java @@ -53,7 +53,7 @@ static void throwRuntimeExceptionAndLog(Logger logger, String errorMessage) { throwRuntimeExceptionAndLog(logger, errorMessage, null, RuntimeException.class); } - static void throwRuntimeExceptionAndLog(Logger logger, String errorMessage, Exception e) { + public static void throwRuntimeExceptionAndLog(Logger logger, String errorMessage, Exception e) { throwRuntimeExceptionAndLog(logger, errorMessage, e, RuntimeException.class); } } diff --git a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java index bf44d67d..5ab61d20 100644 --- a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java +++ b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java @@ -17,6 +17,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.server.ApacheProxyAddressStrategy; import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.interceptor.CorsInterceptor; import com.google.fhir.gateway.interfaces.AccessCheckerFactory; @@ -46,7 +47,7 @@ public class FhirProxyServer extends RestfulServer { // Spring's automatic scanning. @Autowired private Map accessCheckerFactories; - static boolean isDevMode() { + public static boolean isDevMode() { String runMode = System.getenv("RUN_MODE"); return "DEV".equals(runMode); } @@ -77,6 +78,8 @@ protected void initialize() throws ServletException { } catch (IOException e) { ExceptionUtil.throwRuntimeExceptionAndLog(logger, "IOException while initializing", e); } + + setServerAddressStrategy(new ApacheProxyAddressStrategy(true)); } private AccessCheckerFactory chooseAccessCheckerFactory() { diff --git a/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java b/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java index edb50b64..9077ac49 100644 --- a/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java +++ b/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java @@ -19,7 +19,7 @@ import org.apache.http.entity.ContentType; public class ProxyConstants { - // Note we should not set charset here; otherwise GCP FHIR store complains about Content-Type. static final ContentType JSON_PATCH_CONTENT = ContentType.create(Constants.CT_JSON_PATCH); + public static final String HTTP_URL_SEPARATOR = "/"; } diff --git a/server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java b/server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java old mode 100644 new mode 100755 diff --git a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java index 466ef318..f6448350 100644 --- a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java +++ b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java @@ -62,16 +62,11 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.mock.web.MockHttpServletResponse; @RunWith(MockitoJUnitRunner.class) public class BearerAuthorizationInterceptorTest { - private static final Logger logger = - LoggerFactory.getLogger(BearerAuthorizationInterceptorTest.class); - private static final FhirContext fhirContext = FhirContext.forR4(); private BearerAuthorizationInterceptor testInstance; From 7c23ea823b954c3c8e4d6fe757482e3c076347c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:35:53 -0400 Subject: [PATCH 08/10] Bump java-jwt from 4.2.2 to 4.4.0 (#137) --- server/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/pom.xml b/server/pom.xml index 1e977371..b6841677 100755 --- a/server/pom.xml +++ b/server/pom.xml @@ -108,7 +108,7 @@ com.auth0 java-jwt - 4.2.2 + 4.4.0 com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.3.0 plugins diff --git a/pom.xml b/pom.xml index 9d8d1333..1d3d2eb5 100755 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.3.0 pom FHIR Information Gateway diff --git a/server/pom.xml b/server/pom.xml index b6841677..7957299a 100755 --- a/server/pom.xml +++ b/server/pom.xml @@ -21,7 +21,7 @@ com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.3.0 server From 6f990af9fca724e2cea6b21edbe40cd434226e69 Mon Sep 17 00:00:00 2001 From: Reham Muzzamil Date: Thu, 5 Oct 2023 18:15:59 +0500 Subject: [PATCH 10/10] Fetch changes from upstream branch --- charts/fhir-gateway/Chart.yaml | 2 +- charts/fhir-gateway/templates/configmap.yaml | 2 +- charts/fhir-gateway/templates/deployment.yaml | 2 +- charts/fhir-gateway/templates/hpa.yaml | 2 +- charts/fhir-gateway/templates/ingress.yaml | 2 +- charts/fhir-gateway/templates/pdb.yaml | 2 +- charts/fhir-gateway/templates/service.yaml | 2 +- .../templates/serviceaccount.yaml | 2 +- .../templates/tests/test-connection.yaml | 2 +- charts/fhir-gateway/templates/vpa.yaml | 2 +- charts/fhir-gateway/values.yaml | 2 +- .../plugin/PermissionAccessChecker.java | 365 ----------- .../PractitionerDetailsEndpointHelper.java | 533 ----------------- .../gateway/plugin/SyncAccessDecision.java | 482 --------------- .../plugin/PermissionAccessCheckerTest.java | 462 -------------- .../plugin/SyncAccessDecisionTest.java | 565 ------------------ .../fhir/gateway/ResourceFinderImp.java | 98 --- .../gateway/interfaces/ResourceFinder.java | 24 - 18 files changed, 11 insertions(+), 2540 deletions(-) delete mode 100755 plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java delete mode 100644 plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java delete mode 100755 plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java delete mode 100755 plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java delete mode 100755 plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java delete mode 100755 server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java delete mode 100755 server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java diff --git a/charts/fhir-gateway/Chart.yaml b/charts/fhir-gateway/Chart.yaml index 29e5beec..0408fc6d 100644 --- a/charts/fhir-gateway/Chart.yaml +++ b/charts/fhir-gateway/Chart.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/configmap.yaml b/charts/fhir-gateway/templates/configmap.yaml index 563cc7cc..e1de20e2 100644 --- a/charts/fhir-gateway/templates/configmap.yaml +++ b/charts/fhir-gateway/templates/configmap.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/deployment.yaml b/charts/fhir-gateway/templates/deployment.yaml index 74221419..07de2113 100644 --- a/charts/fhir-gateway/templates/deployment.yaml +++ b/charts/fhir-gateway/templates/deployment.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/hpa.yaml b/charts/fhir-gateway/templates/hpa.yaml index 5c2b58c8..d6f5ac9f 100644 --- a/charts/fhir-gateway/templates/hpa.yaml +++ b/charts/fhir-gateway/templates/hpa.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/ingress.yaml b/charts/fhir-gateway/templates/ingress.yaml index a30bcc93..8e12d62a 100644 --- a/charts/fhir-gateway/templates/ingress.yaml +++ b/charts/fhir-gateway/templates/ingress.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/pdb.yaml b/charts/fhir-gateway/templates/pdb.yaml index 17504f2d..58e4f9d8 100644 --- a/charts/fhir-gateway/templates/pdb.yaml +++ b/charts/fhir-gateway/templates/pdb.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/service.yaml b/charts/fhir-gateway/templates/service.yaml index f367b062..7652a830 100644 --- a/charts/fhir-gateway/templates/service.yaml +++ b/charts/fhir-gateway/templates/service.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/serviceaccount.yaml b/charts/fhir-gateway/templates/serviceaccount.yaml index c0fdd270..4f0d28fe 100644 --- a/charts/fhir-gateway/templates/serviceaccount.yaml +++ b/charts/fhir-gateway/templates/serviceaccount.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/tests/test-connection.yaml b/charts/fhir-gateway/templates/tests/test-connection.yaml index 022c4230..a57ff9e7 100644 --- a/charts/fhir-gateway/templates/tests/test-connection.yaml +++ b/charts/fhir-gateway/templates/tests/test-connection.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/templates/vpa.yaml b/charts/fhir-gateway/templates/vpa.yaml index 7dd1c0fa..59337365 100644 --- a/charts/fhir-gateway/templates/vpa.yaml +++ b/charts/fhir-gateway/templates/vpa.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/charts/fhir-gateway/values.yaml b/charts/fhir-gateway/values.yaml index 0572283c..aa1dd133 100644 --- a/charts/fhir-gateway/values.yaml +++ b/charts/fhir-gateway/values.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java deleted file mode 100755 index 8d729b43..00000000 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java +++ /dev/null @@ -1,365 +0,0 @@ -/* - * Copyright 2021-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.fhir.gateway.plugin; - -import static com.google.fhir.gateway.ProxyConstants.SYNC_STRATEGY; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.model.api.IQueryParameterType; -import ca.uhn.fhir.rest.api.RequestTypeEnum; -import ca.uhn.fhir.rest.client.api.IGenericClient; -import ca.uhn.fhir.rest.param.TokenParam; -import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; -import com.auth0.jwt.interfaces.Claim; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.fhir.gateway.*; -import com.google.fhir.gateway.interfaces.*; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import java.util.*; -import java.util.stream.Collectors; -import javax.inject.Named; -import org.apache.commons.lang3.StringUtils; -import org.hl7.fhir.r4.model.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.smartregister.model.practitioner.PractitionerDetails; -import org.smartregister.utils.Constants; - -public class PermissionAccessChecker implements AccessChecker { - private static final Logger logger = LoggerFactory.getLogger(PermissionAccessChecker.class); - private final ResourceFinder resourceFinder; - private final List userRoles; - private SyncAccessDecision syncAccessDecision; - - private PermissionAccessChecker( - String keycloakUUID, - List userRoles, - ResourceFinderImp resourceFinder, - String applicationId, - List careTeamIds, - List locationIds, - List organizationIds, - String syncStrategy) { - Preconditions.checkNotNull(userRoles); - Preconditions.checkNotNull(resourceFinder); - Preconditions.checkNotNull(applicationId); - Preconditions.checkNotNull(careTeamIds); - Preconditions.checkNotNull(organizationIds); - Preconditions.checkNotNull(locationIds); - Preconditions.checkNotNull(syncStrategy); - this.resourceFinder = resourceFinder; - this.userRoles = userRoles; - this.syncAccessDecision = - new SyncAccessDecision( - keycloakUUID, - applicationId, - true, - locationIds, - careTeamIds, - organizationIds, - syncStrategy, - userRoles); - } - - @Override - public AccessDecision checkAccess(RequestDetailsReader requestDetails) { - // For a Bundle requestDetails.getResourceName() returns null - if (requestDetails.getRequestType() == RequestTypeEnum.POST - && requestDetails.getResourceName() == null) { - return processBundle(requestDetails); - - } else { - - boolean userHasRole = - checkUserHasRole( - requestDetails.getResourceName(), requestDetails.getRequestType().name()); - - RequestTypeEnum requestType = requestDetails.getRequestType(); - - switch (requestType) { - case GET: - return processGet(userHasRole); - case DELETE: - return processDelete(userHasRole); - case POST: - return processPost(userHasRole); - case PUT: - return processPut(userHasRole); - default: - // TODO handle other cases like PATCH - return NoOpAccessDecision.accessDenied(); - } - } - } - - private boolean checkUserHasRole(String resourceName, String requestType) { - return checkIfRoleExists(getAdminRoleName(resourceName), this.userRoles) - || checkIfRoleExists(getRelevantRoleName(resourceName, requestType), this.userRoles); - } - - private AccessDecision processGet(boolean userHasRole) { - return getAccessDecision(userHasRole); - } - - private AccessDecision processDelete(boolean userHasRole) { - return getAccessDecision(userHasRole); - } - - private AccessDecision getAccessDecision(boolean userHasRole) { - return userHasRole ? syncAccessDecision : NoOpAccessDecision.accessDenied(); - } - - private AccessDecision processPost(boolean userHasRole) { - return getAccessDecision(userHasRole); - } - - private AccessDecision processPut(boolean userHasRole) { - return getAccessDecision(userHasRole); - } - - private AccessDecision processBundle(RequestDetailsReader requestDetails) { - boolean hasMissingRole = false; - List resourcesInBundle = resourceFinder.findResourcesInBundle(requestDetails); - // Verify Authorization for individual requests in Bundle - for (BundleResources bundleResources : resourcesInBundle) { - if (!checkUserHasRole( - bundleResources.getResource().fhirType(), bundleResources.getRequestType().name())) { - - if (isDevMode()) { - hasMissingRole = true; - logger.info( - "Missing role " - + getRelevantRoleName( - bundleResources.getResource().fhirType(), - bundleResources.getRequestType().name())); - } else { - return NoOpAccessDecision.accessDenied(); - } - } - } - - return (isDevMode() && !hasMissingRole) || !isDevMode() - ? NoOpAccessDecision.accessGranted() - : NoOpAccessDecision.accessDenied(); - } - - private String getRelevantRoleName(String resourceName, String methodType) { - return methodType + "_" + resourceName.toUpperCase(); - } - - private String getAdminRoleName(String resourceName) { - return "MANAGE_" + resourceName.toUpperCase(); - } - - @VisibleForTesting - protected boolean isDevMode() { - return FhirProxyServer.isDevMode(); - } - - private boolean checkIfRoleExists(String roleName, List existingRoles) { - return existingRoles.contains(roleName); - } - - @Named(value = "permission") - static class Factory implements AccessCheckerFactory { - - @VisibleForTesting static final String REALM_ACCESS_CLAIM = "realm_access"; - @VisibleForTesting static final String ROLES = "roles"; - - @VisibleForTesting static final String FHIR_CORE_APPLICATION_ID_CLAIM = "fhir_core_app_id"; - - @VisibleForTesting static final String PROXY_TO_ENV = "PROXY_TO"; - - private List getUserRolesFromJWT(DecodedJWT jwt) { - Claim claim = jwt.getClaim(REALM_ACCESS_CLAIM); - Map roles = claim.asMap(); - List rolesList = (List) roles.get(ROLES); - return rolesList; - } - - private String getApplicationIdFromJWT(DecodedJWT jwt) { - return JwtUtil.getClaimOrDie(jwt, FHIR_CORE_APPLICATION_ID_CLAIM); - } - - private IGenericClient createFhirClientForR4() { - String fhirServer = System.getenv(PROXY_TO_ENV); - FhirContext ctx = FhirContext.forR4(); - IGenericClient client = ctx.newRestfulGenericClient(fhirServer); - return client; - } - - private Composition readCompositionResource(String applicationId) { - IGenericClient client = createFhirClientForR4(); - Bundle compositionBundle = - client - .search() - .forResource(Composition.class) - .where(Composition.IDENTIFIER.exactly().identifier(applicationId)) - .returnBundle(Bundle.class) - .execute(); - List compositionEntries = - compositionBundle != null - ? compositionBundle.getEntry() - : Collections.singletonList(new Bundle.BundleEntryComponent()); - Bundle.BundleEntryComponent compositionEntry = - compositionEntries.size() > 0 ? compositionEntries.get(0) : null; - return compositionEntry != null ? (Composition) compositionEntry.getResource() : null; - } - - private String getBinaryResourceReference(Composition composition) { - List indexes = new ArrayList<>(); - String id = ""; - if (composition != null && composition.getSection() != null) { - indexes = - composition.getSection().stream() - .filter(v -> v.getFocus().getIdentifier() != null) - .filter(v -> v.getFocus().getIdentifier().getValue() != null) - .filter(v -> v.getFocus().getIdentifier().getValue().equals("application")) - .map(v -> composition.getSection().indexOf(v)) - .collect(Collectors.toList()); - Composition.SectionComponent sectionComponent = composition.getSection().get(0); - Reference focus = sectionComponent != null ? sectionComponent.getFocus() : null; - id = focus != null ? focus.getReference() : null; - } - return id; - } - - private Binary findApplicationConfigBinaryResource(String binaryResourceId) { - IGenericClient client = createFhirClientForR4(); - Binary binary = null; - if (!binaryResourceId.isBlank()) { - binary = client.read().resource(Binary.class).withId(binaryResourceId).execute(); - } - return binary; - } - - private String findSyncStrategy(Binary binary) { - byte[] bytes = - binary != null && binary.getDataElement() != null - ? Base64.getDecoder().decode(binary.getDataElement().getValueAsString()) - : null; - String syncStrategy = Constants.EMPTY_STRING; - if (bytes != null) { - String json = new String(bytes); - JsonObject jsonObject = new Gson().fromJson(json, JsonObject.class); - JsonArray jsonArray = jsonObject.getAsJsonArray(SYNC_STRATEGY); - if (jsonArray != null && !jsonArray.isEmpty()) - syncStrategy = jsonArray.get(0).getAsString(); - } - return syncStrategy; - } - - private PractitionerDetails readPractitionerDetails(String keycloakUUID) { - IGenericClient client = createFhirClientForR4(); - // Map<> - Bundle practitionerDetailsBundle = - client - .search() - .forResource(PractitionerDetails.class) - .where(getMapForWhere(keycloakUUID)) - .returnBundle(Bundle.class) - .execute(); - - List practitionerDetailsBundleEntry = - practitionerDetailsBundle.getEntry(); - Bundle.BundleEntryComponent practitionerDetailEntry = - practitionerDetailsBundleEntry != null && practitionerDetailsBundleEntry.size() > 0 - ? practitionerDetailsBundleEntry.get(0) - : null; - return practitionerDetailEntry != null - ? (PractitionerDetails) practitionerDetailEntry.getResource() - : null; - } - - public Map> getMapForWhere(String keycloakUUID) { - Map> hmOut = new HashMap<>(); - // Adding keycloak-uuid - TokenParam tokenParam = new TokenParam("keycloak-uuid"); - tokenParam.setValue(keycloakUUID); - List lst = new ArrayList(); - lst.add(tokenParam); - hmOut.put(PractitionerDetails.SP_KEYCLOAK_UUID, lst); - - return hmOut; - } - - @Override - public AccessChecker create( - DecodedJWT jwt, - HttpFhirClient httpFhirClient, - FhirContext fhirContext, - PatientFinder patientFinder) - throws AuthenticationException { - List userRoles = getUserRolesFromJWT(jwt); - String applicationId = getApplicationIdFromJWT(jwt); - Composition composition = readCompositionResource(applicationId); - String binaryResourceReference = getBinaryResourceReference(composition); - Binary binary = findApplicationConfigBinaryResource(binaryResourceReference); - String syncStrategy = findSyncStrategy(binary); - PractitionerDetails practitionerDetails = readPractitionerDetails(jwt.getSubject()); - List careTeams; - List organizations; - List careTeamIds = new ArrayList<>(); - List organizationIds = new ArrayList<>(); - List locationIds = new ArrayList<>(); - if (StringUtils.isNotBlank(syncStrategy)) { - if (syncStrategy.equals(Constants.CARE_TEAM)) { - careTeams = - practitionerDetails != null - && practitionerDetails.getFhirPractitionerDetails() != null - ? practitionerDetails.getFhirPractitionerDetails().getCareTeams() - : Collections.singletonList(new CareTeam()); - for (CareTeam careTeam : careTeams) { - if (careTeam.getIdElement() != null) { - careTeamIds.add(careTeam.getIdElement().getIdPart()); - } - } - } else if (syncStrategy.equals(Constants.ORGANIZATION)) { - organizations = - practitionerDetails != null - && practitionerDetails.getFhirPractitionerDetails() != null - ? practitionerDetails.getFhirPractitionerDetails().getOrganizations() - : Collections.singletonList(new Organization()); - for (Organization organization : organizations) { - if (organization.getIdElement() != null) { - organizationIds.add(organization.getIdElement().getIdPart()); - } - } - } else if (syncStrategy.equals(Constants.LOCATION)) { - locationIds = - practitionerDetails != null - && practitionerDetails.getFhirPractitionerDetails() != null - ? PractitionerDetailsEndpointHelper.getAttributedLocations( - practitionerDetails.getFhirPractitionerDetails().getLocationHierarchyList()) - : locationIds; - } - } - return new PermissionAccessChecker( - jwt.getSubject(), - userRoles, - ResourceFinderImp.getInstance(fhirContext), - applicationId, - careTeamIds, - locationIds, - organizationIds, - syncStrategy); - } - } -} diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java deleted file mode 100644 index 458558ff..00000000 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java +++ /dev/null @@ -1,533 +0,0 @@ -package com.google.fhir.gateway.plugin; - -import static org.smartregister.utils.Constants.EMPTY_STRING; - -import ca.uhn.fhir.rest.client.api.IGenericClient; -import ca.uhn.fhir.rest.gclient.ReferenceClientParam; -import com.google.fhir.gateway.ProxyConstants; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.apache.commons.lang3.StringUtils; -import org.hl7.fhir.r4.model.BaseResource; -import org.hl7.fhir.r4.model.Bundle; -import org.hl7.fhir.r4.model.CareTeam; -import org.hl7.fhir.r4.model.Enumerations; -import org.hl7.fhir.r4.model.Group; -import org.hl7.fhir.r4.model.Identifier; -import org.hl7.fhir.r4.model.Location; -import org.hl7.fhir.r4.model.Organization; -import org.hl7.fhir.r4.model.OrganizationAffiliation; -import org.hl7.fhir.r4.model.Practitioner; -import org.hl7.fhir.r4.model.PractitionerRole; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.smartregister.model.location.LocationHierarchy; -import org.smartregister.model.location.ParentChildrenMap; -import org.smartregister.model.practitioner.FhirPractitionerDetails; -import org.smartregister.model.practitioner.PractitionerDetails; -import org.smartregister.utils.Constants; -import org.springframework.lang.Nullable; - -public class PractitionerDetailsEndpointHelper { - private static final Logger logger = - LoggerFactory.getLogger(PractitionerDetailsEndpointHelper.class); - public static final String PRACTITIONER_GROUP_CODE = "405623001"; - public static final String HTTP_SNOMED_INFO_SCT = "http://snomed.info/sct"; - public static final Bundle EMPTY_BUNDLE = new Bundle(); - private IGenericClient r4FhirClient; - - public PractitionerDetailsEndpointHelper(IGenericClient fhirClient) { - this.r4FhirClient = fhirClient; - } - - private IGenericClient getFhirClientForR4() { - return r4FhirClient; - } - - public PractitionerDetails getPractitionerDetailsByKeycloakId(String keycloakUuid) { - PractitionerDetails practitionerDetails = new PractitionerDetails(); - - logger.info("Searching for practitioner with identifier: " + keycloakUuid); - Practitioner practitioner = getPractitionerByIdentifier(keycloakUuid); - - if (practitioner != null) { - - practitionerDetails = getPractitionerDetailsByPractitioner(practitioner); - - } else { - logger.error("Practitioner with KC identifier: " + keycloakUuid + " not found"); - practitionerDetails.setId(Constants.PRACTITIONER_NOT_FOUND); - } - - return practitionerDetails; - } - - public Bundle getSupervisorPractitionerDetailsByKeycloakId(String keycloakUuid) { - Bundle bundle = new Bundle(); - - logger.info("Searching for practitioner with identifier: " + keycloakUuid); - Practitioner practitioner = getPractitionerByIdentifier(keycloakUuid); - - if (practitioner != null) { - - bundle = getAttributedPractitionerDetailsByPractitioner(practitioner); - - } else { - logger.error("Practitioner with KC identifier: " + keycloakUuid + " not found"); - } - - return bundle; - } - - private Bundle getAttributedPractitionerDetailsByPractitioner(Practitioner practitioner) { - Bundle responseBundle = new Bundle(); - List attributedPractitioners = new ArrayList<>(); - PractitionerDetails practitionerDetails = getPractitionerDetailsByPractitioner(practitioner); - - List careTeamList = practitionerDetails.getFhirPractitionerDetails().getCareTeams(); - // Get other guys. - - List careTeamManagingOrganizationIds = - getManagingOrganizationsOfCareTeamIds(careTeamList); - List supervisorCareTeamOrganizationLocationIds = - getOrganizationAffiliationsByOrganizationIds(careTeamManagingOrganizationIds); - List officialLocationIds = - getOfficialLocationIdentifiersByLocationIds(supervisorCareTeamOrganizationLocationIds); - List locationHierarchies = - getLocationsHierarchyByOfficialLocationIdentifiers(officialLocationIds); - List attributedLocationsList = getAttributedLocations(locationHierarchies); - List attributedOrganizationIds = - getOrganizationIdsByLocationIds(attributedLocationsList); - - // Get care teams by organization Ids - List attributedCareTeams = getCareTeamsByOrganizationIds(attributedOrganizationIds); - - for (CareTeam careTeam : careTeamList) { - attributedCareTeams.removeIf(it -> it.getId().equals(careTeam.getId())); - } - - careTeamList.addAll(attributedCareTeams); - - for (CareTeam careTeam : careTeamList) { - // Add current supervisor practitioners - attributedPractitioners.addAll( - careTeam.getParticipant().stream() - .filter( - it -> - it.hasMember() - && it.getMember() - .getReference() - .startsWith(Enumerations.ResourceType.PRACTITIONER.toCode())) - .map( - it -> - getPractitionerByIdentifier( - getReferenceIDPart(it.getMember().getReference()))) - .collect(Collectors.toList())); - } - - List bundleEntryComponentList = new ArrayList<>(); - - for (Practitioner attributedPractitioner : attributedPractitioners) { - bundleEntryComponentList.add( - new Bundle.BundleEntryComponent() - .setResource(getPractitionerDetailsByPractitioner(attributedPractitioner))); - } - - responseBundle.setEntry(bundleEntryComponentList); - responseBundle.setTotal(bundleEntryComponentList.size()); - return responseBundle; - } - - @NotNull - public static List getAttributedLocations(List locationHierarchies) { - List parentChildrenList = - locationHierarchies.stream() - .flatMap( - locationHierarchy -> - locationHierarchy - .getLocationHierarchyTree() - .getLocationsHierarchy() - .getParentChildren() - .stream()) - .collect(Collectors.toList()); - List attributedLocationsList = - parentChildrenList.stream() - .flatMap(parentChildren -> parentChildren.getChildIdentifiers().stream()) - .map(it -> getReferenceIDPart(it.toString())) - .collect(Collectors.toList()); - return attributedLocationsList; - } - - private List getOrganizationIdsByLocationIds(List attributedLocationsList) { - if (attributedLocationsList == null || attributedLocationsList.isEmpty()) { - return new ArrayList<>(); - } - - Bundle organizationAffiliationsBundle = - getFhirClientForR4() - .search() - .forResource(OrganizationAffiliation.class) - .where(OrganizationAffiliation.LOCATION.hasAnyOfIds(attributedLocationsList)) - .returnBundle(Bundle.class) - .execute(); - - return organizationAffiliationsBundle.getEntry().stream() - .map( - bundleEntryComponent -> - getReferenceIDPart( - ((OrganizationAffiliation) bundleEntryComponent.getResource()) - .getOrganization() - .getReference())) - .distinct() - .collect(Collectors.toList()); - } - - private String getPractitionerIdentifier(Practitioner practitioner) { - String practitionerId = EMPTY_STRING; - if (practitioner.getIdElement() != null && practitioner.getIdElement().getIdPart() != null) { - practitionerId = practitioner.getIdElement().getIdPart(); - } - return practitionerId; - } - - private PractitionerDetails getPractitionerDetailsByPractitioner(Practitioner practitioner) { - - PractitionerDetails practitionerDetails = new PractitionerDetails(); - FhirPractitionerDetails fhirPractitionerDetails = new FhirPractitionerDetails(); - String practitionerId = getPractitionerIdentifier(practitioner); - - logger.info("Searching for care teams for practitioner with id: " + practitioner); - Bundle careTeams = getCareTeams(practitionerId); - List careTeamsList = mapBundleToCareTeams(careTeams); - fhirPractitionerDetails.setCareTeams(careTeamsList); - fhirPractitionerDetails.setPractitioners(Arrays.asList(practitioner)); - - logger.info("Searching for Organizations tied with CareTeams: "); - List careTeamManagingOrganizationIds = - getManagingOrganizationsOfCareTeamIds(careTeamsList); - - Bundle careTeamManagingOrganizations = getOrganizationsById(careTeamManagingOrganizationIds); - logger.info("Managing Organization are fetched"); - - List managingOrganizationTeams = - mapBundleToOrganizations(careTeamManagingOrganizations); - - logger.info("Searching for organizations of practitioner with id: " + practitioner); - - List practitionerRoleList = - getPractitionerRolesByPractitionerId(practitionerId); - logger.info("Practitioner Roles are fetched"); - - List practitionerOrganizationIds = - getOrganizationIdsByPractitionerRoles(practitionerRoleList); - - Bundle practitionerOrganizations = getOrganizationsById(practitionerOrganizationIds); - - List teams = mapBundleToOrganizations(practitionerOrganizations); - // TODO Fix Distinct - List bothOrganizations = - Stream.concat(managingOrganizationTeams.stream(), teams.stream()) - .distinct() - .collect(Collectors.toList()); - - fhirPractitionerDetails.setOrganizations(bothOrganizations); - fhirPractitionerDetails.setPractitionerRoles(practitionerRoleList); - - Bundle groupsBundle = getGroupsAssignedToPractitioner(practitionerId); - logger.info("Groups are fetched"); - - List groupsList = mapBundleToGroups(groupsBundle); - fhirPractitionerDetails.setGroups(groupsList); - fhirPractitionerDetails.setId(practitionerId); - - logger.info("Searching for locations by organizations"); - - Bundle organizationAffiliationsBundle = - getOrganizationAffiliationsByOrganizationIdsBundle( - Stream.concat( - careTeamManagingOrganizationIds.stream(), practitionerOrganizationIds.stream()) - .distinct() - .collect(Collectors.toList())); - - List organizationAffiliations = - mapBundleToOrganizationAffiliation(organizationAffiliationsBundle); - - fhirPractitionerDetails.setOrganizationAffiliations(organizationAffiliations); - - List locationIds = - getLocationIdentifiersByOrganizationAffiliations(organizationAffiliations); - - List locationsIdentifiers = - getOfficialLocationIdentifiersByLocationIds( - locationIds); // TODO Investigate why the Location ID and official identifiers are - // different - - logger.info("Searching for location hierarchy list by locations identifiers"); - List locationHierarchyList = - getLocationsHierarchyByOfficialLocationIdentifiers(locationsIdentifiers); - fhirPractitionerDetails.setLocationHierarchyList(locationHierarchyList); - - logger.info("Searching for locations by ids"); - List locationsList = getLocationsByIds(locationIds); - fhirPractitionerDetails.setLocations(locationsList); - - practitionerDetails.setId(practitionerId); - practitionerDetails.setFhirPractitionerDetails(fhirPractitionerDetails); - - return practitionerDetails; - } - - private List mapBundleToOrganizations(Bundle organizationBundle) { - return organizationBundle.getEntry().stream() - .map(bundleEntryComponent -> (Organization) bundleEntryComponent.getResource()) - .collect(Collectors.toList()); - } - - private Bundle getGroupsAssignedToPractitioner(String practitionerId) { - return getFhirClientForR4() - .search() - .forResource(Group.class) - .where(Group.MEMBER.hasId(practitionerId)) - .where(Group.CODE.exactly().systemAndCode(HTTP_SNOMED_INFO_SCT, PRACTITIONER_GROUP_CODE)) - .returnBundle(Bundle.class) - .execute(); - } - - public static Predicate distinctByKey(Function keyExtractor) { - Set seen = ConcurrentHashMap.newKeySet(); - return t -> seen.add(keyExtractor.apply(t)); - } - - private List getPractitionerRolesByPractitionerId(String practitionerId) { - Bundle practitionerRoles = getPractitionerRoles(practitionerId); - return mapBundleToPractitionerRolesWithOrganization(practitionerRoles); - } - - private List getOrganizationIdsByPractitionerRoles( - List practitionerRoles) { - return practitionerRoles.stream() - .filter(practitionerRole -> practitionerRole.hasOrganization()) - .map(it -> getReferenceIDPart(it.getOrganization().getReference())) - .collect(Collectors.toList()); - } - - private Practitioner getPractitionerByIdentifier(String identifier) { - Bundle resultBundle = - getFhirClientForR4() - .search() - .forResource(Practitioner.class) - .where(Practitioner.IDENTIFIER.exactly().identifier(identifier)) - .returnBundle(Bundle.class) - .execute(); - - return resultBundle != null - ? (Practitioner) resultBundle.getEntryFirstRep().getResource() - : null; - } - - private List getCareTeamsByOrganizationIds(List organizationIds) { - if (organizationIds.isEmpty()) return new ArrayList<>(); - - Bundle bundle = - getFhirClientForR4() - .search() - .forResource(CareTeam.class) - .where( - CareTeam.PARTICIPANT.hasAnyOfIds( - organizationIds.stream() - .map( - it -> - Enumerations.ResourceType.ORGANIZATION.toCode() - + Constants.FORWARD_SLASH - + it) - .collect(Collectors.toList()))) - .returnBundle(Bundle.class) - .execute(); - - return bundle.getEntry().stream() - .filter(it -> ((CareTeam) it.getResource()).hasManagingOrganization()) - .map(it -> ((CareTeam) it.getResource())) - .collect(Collectors.toList()); - } - - private Bundle getCareTeams(String practitionerId) { - logger.info("Searching for Care Teams with practitioner id :" + practitionerId); - - return getFhirClientForR4() - .search() - .forResource(CareTeam.class) - .where( - CareTeam.PARTICIPANT.hasId( - Enumerations.ResourceType.PRACTITIONER.toCode() - + Constants.FORWARD_SLASH - + practitionerId)) - .returnBundle(Bundle.class) - .execute(); - } - - private Bundle getPractitionerRoles(String practitionerId) { - logger.info("Searching for Practitioner roles with practitioner id :" + practitionerId); - return getFhirClientForR4() - .search() - .forResource(PractitionerRole.class) - .where(PractitionerRole.PRACTITIONER.hasId(practitionerId)) - .returnBundle(Bundle.class) - .execute(); - } - - private static String getReferenceIDPart(String reference) { - return reference.substring(reference.indexOf(Constants.FORWARD_SLASH) + 1); - } - - private Bundle getOrganizationsById(List organizationIds) { - return organizationIds.isEmpty() - ? EMPTY_BUNDLE - : getFhirClientForR4() - .search() - .forResource(Organization.class) - .where(new ReferenceClientParam(BaseResource.SP_RES_ID).hasAnyOfIds(organizationIds)) - .returnBundle(Bundle.class) - .execute(); - } - - private @Nullable List getLocationsByIds(List locationIds) { - if (locationIds == null || locationIds.isEmpty()) { - return new ArrayList<>(); - } - - Bundle locationsBundle = - getFhirClientForR4() - .search() - .forResource(Location.class) - .where(new ReferenceClientParam(BaseResource.SP_RES_ID).hasAnyOfIds(locationIds)) - .returnBundle(Bundle.class) - .execute(); - - return locationsBundle.getEntry().stream() - .map(bundleEntryComponent -> ((Location) bundleEntryComponent.getResource())) - .collect(Collectors.toList()); - } - - private @Nullable List getOfficialLocationIdentifiersByLocationIds( - List locationIds) { - if (locationIds == null || locationIds.isEmpty()) { - return new ArrayList<>(); - } - - List locations = getLocationsByIds(locationIds); - - return locations.stream() - .map( - it -> - it.getIdentifier().stream() - .filter( - id -> id.hasUse() && id.getUse().equals(Identifier.IdentifierUse.OFFICIAL)) - .map(it2 -> it2.getValue()) - .collect(Collectors.toList())) - .flatMap(it3 -> it3.stream()) - .collect(Collectors.toList()); - } - - private List getOrganizationAffiliationsByOrganizationIds(List organizationIds) { - if (organizationIds == null || organizationIds.isEmpty()) { - return new ArrayList<>(); - } - Bundle organizationAffiliationsBundle = - getOrganizationAffiliationsByOrganizationIdsBundle(organizationIds); - List organizationAffiliations = - mapBundleToOrganizationAffiliation(organizationAffiliationsBundle); - return getLocationIdentifiersByOrganizationAffiliations(organizationAffiliations); - } - - private Bundle getOrganizationAffiliationsByOrganizationIdsBundle(List organizationIds) { - return organizationIds.isEmpty() - ? EMPTY_BUNDLE - : getFhirClientForR4() - .search() - .forResource(OrganizationAffiliation.class) - .where(OrganizationAffiliation.PRIMARY_ORGANIZATION.hasAnyOfIds(organizationIds)) - .returnBundle(Bundle.class) - .execute(); - } - - private List getLocationIdentifiersByOrganizationAffiliations( - List organizationAffiliations) { - - return organizationAffiliations.stream() - .map( - organizationAffiliation -> - getReferenceIDPart( - organizationAffiliation.getLocation().stream() - .findFirst() - .get() - .getReference())) - .collect(Collectors.toList()); - } - - private List getManagingOrganizationsOfCareTeamIds(List careTeamsList) { - logger.info("Searching for Organizations with care teams list of size:" + careTeamsList.size()); - return careTeamsList.stream() - .filter(careTeam -> careTeam.hasManagingOrganization()) - .flatMap(it -> it.getManagingOrganization().stream()) - .map(it -> getReferenceIDPart(it.getReference())) - .collect(Collectors.toList()); - } - - private List mapBundleToCareTeams(Bundle careTeams) { - return careTeams.getEntry().stream() - .map(bundleEntryComponent -> (CareTeam) bundleEntryComponent.getResource()) - .collect(Collectors.toList()); - } - - private List mapBundleToPractitionerRolesWithOrganization( - Bundle practitionerRoles) { - return practitionerRoles.getEntry().stream() - .map(it -> (PractitionerRole) it.getResource()) - .collect(Collectors.toList()); - } - - private List mapBundleToGroups(Bundle groupsBundle) { - return groupsBundle.getEntry().stream() - .map(bundleEntryComponent -> (Group) bundleEntryComponent.getResource()) - .collect(Collectors.toList()); - } - - private List mapBundleToOrganizationAffiliation( - Bundle organizationAffiliationBundle) { - return organizationAffiliationBundle.getEntry().stream() - .map(bundleEntryComponent -> (OrganizationAffiliation) bundleEntryComponent.getResource()) - .collect(Collectors.toList()); - } - - private List getLocationsHierarchyByOfficialLocationIdentifiers( - List officialLocationIdentifiers) { - if (officialLocationIdentifiers.isEmpty()) return new ArrayList<>(); - - Bundle bundle = - getFhirClientForR4() - .search() - .forResource(LocationHierarchy.class) - .where(LocationHierarchy.IDENTIFIER.exactly().codes(officialLocationIdentifiers)) - .returnBundle(Bundle.class) - .execute(); - - return bundle.getEntry().stream() - .map(it -> ((LocationHierarchy) it.getResource())) - .collect(Collectors.toList()); - } - - public static String createSearchTagValues(Map.Entry entry) { - return entry.getKey() - + ProxyConstants.CODE_URL_VALUE_SEPARATOR - + StringUtils.join( - entry.getValue(), - ProxyConstants.PARAM_VALUES_SEPARATOR - + entry.getKey() - + ProxyConstants.CODE_URL_VALUE_SEPARATOR); - } -} diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java deleted file mode 100755 index bcb53aa8..00000000 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java +++ /dev/null @@ -1,482 +0,0 @@ -/* - * Copyright 2021-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.fhir.gateway.plugin; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.parser.IParser; -import ca.uhn.fhir.rest.api.RequestTypeEnum; -import ca.uhn.fhir.rest.client.api.IGenericClient; -import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; -import com.google.common.annotations.VisibleForTesting; -import com.google.fhir.gateway.ExceptionUtil; -import com.google.fhir.gateway.ProxyConstants; -import com.google.fhir.gateway.interfaces.AccessDecision; -import com.google.fhir.gateway.interfaces.RequestDetailsReader; -import com.google.fhir.gateway.interfaces.RequestMutation; -import com.google.gson.Gson; -import java.io.FileReader; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import javax.annotation.Nullable; -import lombok.Getter; -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpResponse; -import org.apache.http.impl.client.BasicResponseHandler; -import org.apache.http.util.TextUtils; -import org.hl7.fhir.instance.model.api.IBaseResource; -import org.hl7.fhir.r4.model.Bundle; -import org.hl7.fhir.r4.model.ListResource; -import org.hl7.fhir.r4.model.OperationOutcome; -import org.hl7.fhir.r4.model.Resource; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class SyncAccessDecision implements AccessDecision { - public static final String SYNC_FILTER_IGNORE_RESOURCES_FILE_ENV = - "SYNC_FILTER_IGNORE_RESOURCES_FILE"; - public static final String MATCHES_ANY_VALUE = "ANY_VALUE"; - private static final Logger logger = LoggerFactory.getLogger(SyncAccessDecision.class); - private static final int LENGTH_OF_SEARCH_PARAM_AND_EQUALS = 5; - private final String syncStrategy; - private final String applicationId; - private final boolean accessGranted; - private final List careTeamIds; - private final List locationIds; - private final List organizationIds; - private final List roles; - private IgnoredResourcesConfig config; - private String keycloakUUID; - private Gson gson = new Gson(); - private FhirContext fhirR4Context = FhirContext.forR4(); - private IParser fhirR4JsonParser = fhirR4Context.newJsonParser(); - private IGenericClient fhirR4Client; - - private PractitionerDetailsEndpointHelper practitionerDetailsEndpointHelper; - - public SyncAccessDecision( - String keycloakUUID, - String applicationId, - boolean accessGranted, - List locationIds, - List careTeamIds, - List organizationIds, - String syncStrategy, - List roles) { - this.keycloakUUID = keycloakUUID; - this.applicationId = applicationId; - this.accessGranted = accessGranted; - this.careTeamIds = careTeamIds; - this.locationIds = locationIds; - this.organizationIds = organizationIds; - this.syncStrategy = syncStrategy; - this.config = getSkippedResourcesConfigs(); - this.roles = roles; - try { - setFhirR4Client( - fhirR4Context.newRestfulGenericClient( - System.getenv(PermissionAccessChecker.Factory.PROXY_TO_ENV))); - } catch (NullPointerException e) { - logger.error(e.getMessage()); - } - - this.practitionerDetailsEndpointHelper = new PractitionerDetailsEndpointHelper(fhirR4Client); - } - - @Override - public boolean canAccess() { - return accessGranted; - } - - @Override - public RequestMutation getRequestMutation(RequestDetailsReader requestDetailsReader) { - - RequestMutation requestMutation = null; - if (isSyncUrl(requestDetailsReader)) { - if (locationIds.isEmpty() && careTeamIds.isEmpty() && organizationIds.isEmpty()) { - - ForbiddenOperationException forbiddenOperationException = - new ForbiddenOperationException( - "User un-authorized to " - + requestDetailsReader.getRequestType() - + " /" - + requestDetailsReader.getRequestPath() - + ". User assignment or sync strategy not configured correctly"); - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, forbiddenOperationException.getMessage(), forbiddenOperationException); - } - - // Skip app-wide global resource requests - if (!shouldSkipDataFiltering(requestDetailsReader)) { - List syncFilterParameterValues = - addSyncFilters(getSyncTags(locationIds, careTeamIds, organizationIds)); - requestMutation = - RequestMutation.builder() - .queryParams( - Map.of( - ProxyConstants.TAG_SEARCH_PARAM, - Arrays.asList(StringUtils.join(syncFilterParameterValues, ",")))) - .build(); - } - } - - return requestMutation; - } - - /** - * Adds filters to the {@link RequestDetailsReader} for the _tag property to allow filtering by - * specific code-url-values that match specific locations, teams or organisations - * - * @param syncTags - * @return the extra query Parameter values - */ - private List addSyncFilters(Map syncTags) { - List paramValues = new ArrayList<>(); - - for (var entry : syncTags.entrySet()) { - paramValues.add(PractitionerDetailsEndpointHelper.createSearchTagValues(entry)); - } - - return paramValues; - } - - /** NOTE: Always return a null whenever you want to skip post-processing */ - @Override - public String postProcess(RequestDetailsReader request, HttpResponse response) - throws IOException { - - String resultContent = null; - Resource resultContentBundle; - String gatewayMode = request.getHeader(Constants.FHIR_GATEWAY_MODE); - - if (StringUtils.isNotBlank(gatewayMode)) { - - resultContent = new BasicResponseHandler().handleResponse(response); - IBaseResource responseResource = fhirR4JsonParser.parseResource(resultContent); - - switch (gatewayMode) { - case Constants.LIST_ENTRIES: - resultContentBundle = postProcessModeListEntries(responseResource); - break; - - default: - String exceptionMessage = - "The FHIR Gateway Mode header is configured with an un-recognized value of \'" - + gatewayMode - + '\''; - OperationOutcome operationOutcome = createOperationOutcome(exceptionMessage); - - resultContentBundle = operationOutcome; - } - - if (resultContentBundle != null) - resultContent = fhirR4JsonParser.encodeResourceToString(resultContentBundle); - } - - if (includeAttributedPractitioners(request.getRequestPath())) { - Bundle practitionerDetailsBundle = - this.practitionerDetailsEndpointHelper.getSupervisorPractitionerDetailsByKeycloakId( - keycloakUUID); - resultContent = fhirR4JsonParser.encodeResourceToString(practitionerDetailsBundle); - } - - return resultContent; - } - - private boolean includeAttributedPractitioners(String requestPath) { - return Constants.SYNC_STRATEGY_LOCATION.equalsIgnoreCase(syncStrategy) - && roles.contains(Constants.ROLE_SUPERVISOR) - && Constants.ENDPOINT_PRACTITIONER_DETAILS.equals(requestPath); - } - - @NotNull - private static OperationOutcome createOperationOutcome(String exception) { - OperationOutcome operationOutcome = new OperationOutcome(); - OperationOutcome.OperationOutcomeIssueComponent operationOutcomeIssueComponent = - new OperationOutcome.OperationOutcomeIssueComponent(); - operationOutcomeIssueComponent.setSeverity(OperationOutcome.IssueSeverity.ERROR); - operationOutcomeIssueComponent.setCode(OperationOutcome.IssueType.PROCESSING); - operationOutcomeIssueComponent.setDiagnostics(exception); - operationOutcome.setIssue(Arrays.asList(operationOutcomeIssueComponent)); - return operationOutcome; - } - - @NotNull - private static Bundle processListEntriesGatewayModeByListResource( - ListResource responseListResource) { - Bundle requestBundle = new Bundle(); - requestBundle.setType(Bundle.BundleType.BATCH); - - for (ListResource.ListEntryComponent listEntryComponent : responseListResource.getEntry()) { - requestBundle.addEntry( - createBundleEntryComponent( - Bundle.HTTPVerb.GET, listEntryComponent.getItem().getReference(), null)); - } - return requestBundle; - } - - private Bundle processListEntriesGatewayModeByBundle(IBaseResource responseResource) { - Bundle requestBundle = new Bundle(); - requestBundle.setType(Bundle.BundleType.BATCH); - - List bundleEntryComponentList = - ((Bundle) responseResource) - .getEntry().stream() - .filter(it -> it.getResource() instanceof ListResource) - .flatMap( - bundleEntryComponent -> - ((ListResource) bundleEntryComponent.getResource()).getEntry().stream()) - .map( - listEntryComponent -> - createBundleEntryComponent( - Bundle.HTTPVerb.GET, listEntryComponent.getItem().getReference(), null)) - .collect(Collectors.toList()); - - return requestBundle.setEntry(bundleEntryComponentList); - } - - @NotNull - private static Bundle.BundleEntryComponent createBundleEntryComponent( - Bundle.HTTPVerb method, String requestPath, @Nullable String condition) { - - Bundle.BundleEntryComponent bundleEntryComponent = new Bundle.BundleEntryComponent(); - bundleEntryComponent.setRequest( - new Bundle.BundleEntryRequestComponent() - .setMethod(method) - .setUrl(requestPath) - .setIfMatch(condition)); - - return bundleEntryComponent; - } - - /** - * Generates a Bundle result from making a batch search request with the contained entries in the - * List as parameters - * - * @param responseResource FHIR Resource result returned byt the HTTPResponse - * @return String content of the result Bundle - */ - private Bundle postProcessModeListEntries(IBaseResource responseResource) { - - Bundle requestBundle = null; - - if (responseResource instanceof ListResource && ((ListResource) responseResource).hasEntry()) { - - requestBundle = processListEntriesGatewayModeByListResource((ListResource) responseResource); - - } else if (responseResource instanceof Bundle) { - - requestBundle = processListEntriesGatewayModeByBundle(responseResource); - } - - return fhirR4Client.transaction().withBundle(requestBundle).execute(); - } - - /** - * Generates a map of Code.url to multiple Code.Value which contains all the possible filters that - * will be used in syncing - * - * @param locationIds - * @param careTeamIds - * @param organizationIds - * @return Pair of URL to [Code.url, [Code.Value]] map. The URL is complete url - */ - private Map getSyncTags( - List locationIds, List careTeamIds, List organizationIds) { - StringBuilder sb = new StringBuilder(); - Map map = new HashMap<>(); - - sb.append(ProxyConstants.TAG_SEARCH_PARAM); - sb.append(ProxyConstants.Literals.EQUALS); - - addTags(ProxyConstants.LOCATION_TAG_URL, locationIds, map, sb); - addTags(ProxyConstants.ORGANISATION_TAG_URL, organizationIds, map, sb); - addTags(ProxyConstants.CARE_TEAM_TAG_URL, careTeamIds, map, sb); - - return map; - } - - private void addTags( - String tagUrl, - List values, - Map map, - StringBuilder urlStringBuilder) { - int len = values.size(); - if (len > 0) { - if (urlStringBuilder.length() - != (ProxyConstants.TAG_SEARCH_PARAM + ProxyConstants.Literals.EQUALS).length()) { - urlStringBuilder.append(ProxyConstants.PARAM_VALUES_SEPARATOR); - } - - map.put(tagUrl, values.toArray(new String[0])); - - int i = 0; - for (String tagValue : values) { - urlStringBuilder.append(tagUrl); - urlStringBuilder.append(ProxyConstants.CODE_URL_VALUE_SEPARATOR); - urlStringBuilder.append(tagValue); - - if (i != len - 1) { - urlStringBuilder.append(ProxyConstants.PARAM_VALUES_SEPARATOR); - } - i++; - } - } - } - - private boolean isSyncUrl(RequestDetailsReader requestDetailsReader) { - if (requestDetailsReader.getRequestType() == RequestTypeEnum.GET - && !TextUtils.isEmpty(requestDetailsReader.getResourceName())) { - String requestPath = requestDetailsReader.getRequestPath(); - return isResourceTypeRequest( - requestPath.replace(requestDetailsReader.getFhirServerBase(), "")); - } - - return false; - } - - private boolean isResourceTypeRequest(String requestPath) { - if (!TextUtils.isEmpty(requestPath)) { - String[] sections = requestPath.split(ProxyConstants.HTTP_URL_SEPARATOR); - - return sections.length == 1 || (sections.length == 2 && TextUtils.isEmpty(sections[1])); - } - - return false; - } - - @VisibleForTesting - protected IgnoredResourcesConfig getIgnoredResourcesConfigFileConfiguration(String configFile) { - if (configFile != null && !configFile.isEmpty()) { - try { - config = gson.fromJson(new FileReader(configFile), IgnoredResourcesConfig.class); - if (config == null || config.entries == null) { - throw new IllegalArgumentException("A map with a single `entries` array expected!"); - } - for (IgnoredResourcesConfig entry : config.entries) { - if (entry.getPath() == null) { - throw new IllegalArgumentException("Allow-list entries should have a path."); - } - } - - } catch (IOException e) { - logger.error("IO error while reading sync-filter skip-list config file {}", configFile); - } - } - - return config; - } - - @VisibleForTesting - protected IgnoredResourcesConfig getSkippedResourcesConfigs() { - return getIgnoredResourcesConfigFileConfiguration( - System.getenv(SYNC_FILTER_IGNORE_RESOURCES_FILE_ENV)); - } - - /** - * This method checks the request to ensure the path, request type and parameters match values in - * the hapi_sync_filter_ignored_queries configuration - */ - private boolean shouldSkipDataFiltering(RequestDetailsReader requestDetailsReader) { - if (config == null) return false; - - for (IgnoredResourcesConfig entry : config.entries) { - - if (!entry.getPath().equals(requestDetailsReader.getRequestPath())) { - continue; - } - - if (entry.getMethodType() != null - && !entry.getMethodType().equals(requestDetailsReader.getRequestType().name())) { - continue; - } - - for (Map.Entry expectedParam : entry.getQueryParams().entrySet()) { - String[] actualQueryValue = - requestDetailsReader.getParameters().get(expectedParam.getKey()); - - if (actualQueryValue == null) { - return true; - } - - if (MATCHES_ANY_VALUE.equals(expectedParam.getValue())) { - return true; - } else { - if (actualQueryValue.length != 1) { - // We currently do not support multivalued query params in skip-lists. - return false; - } - - if (expectedParam.getValue() instanceof List) { - return CollectionUtils.isEqualCollection( - (List) expectedParam.getValue(), Arrays.asList(actualQueryValue[0].split(","))); - - } else if (actualQueryValue[0].equals(expectedParam.getValue())) { - return true; - } - } - } - } - return false; - } - - @VisibleForTesting - protected void setSkippedResourcesConfig(IgnoredResourcesConfig config) { - this.config = config; - } - - @VisibleForTesting - protected void setFhirR4Context(FhirContext fhirR4Context) { - this.fhirR4Context = fhirR4Context; - } - - @VisibleForTesting - public void setFhirR4Client(IGenericClient fhirR4Client) { - this.fhirR4Client = fhirR4Client; - } - - class IgnoredResourcesConfig { - @Getter List entries; - @Getter private String path; - @Getter private String methodType; - @Getter private Map queryParams; - - @Override - public String toString() { - return "SkippedFilesConfig{" - + methodType - + " path=" - + path - + " fhirResources=" - + Arrays.toString(queryParams.entrySet().toArray()) - + '}'; - } - } - - public static final class Constants { - public static final String FHIR_GATEWAY_MODE = "fhir-gateway-mode"; - public static final String LIST_ENTRIES = "list-entries"; - public static final String ROLE_SUPERVISOR = "SUPERVISOR"; - public static final String ENDPOINT_PRACTITIONER_DETAILS = "practitioner-details"; - public static final String SYNC_STRATEGY_LOCATION = "Location"; - } -} diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java deleted file mode 100755 index 99844324..00000000 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java +++ /dev/null @@ -1,462 +0,0 @@ -/* - * Copyright 2021-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.fhir.gateway.plugin; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.mockito.Mockito.when; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.rest.api.RequestTypeEnum; -import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; -import com.auth0.jwt.interfaces.Claim; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.google.common.io.Resources; -import com.google.fhir.gateway.PatientFinderImp; -import com.google.fhir.gateway.interfaces.AccessChecker; -import com.google.fhir.gateway.interfaces.RequestDetailsReader; -import java.io.IOException; -import java.net.URL; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import org.hl7.fhir.r4.model.Enumerations; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; - -@RunWith(MockitoJUnitRunner.class) -@Ignore -public class PermissionAccessCheckerTest { - - @Mock protected DecodedJWT jwtMock; - - @Mock protected Claim claimMock; - - // TODO consider making a real request object from a URL string to avoid over-mocking. - @Mock protected RequestDetailsReader requestMock; - - // Note this is an expensive class to instantiate, so we only do this once for all tests. - protected static final FhirContext fhirContext = FhirContext.forR4(); - - void setUpFhirBundle(String filename) throws IOException { - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - URL url = Resources.getResource(filename); - byte[] obsBytes = Resources.toByteArray(url); - when(requestMock.loadRequestContents()).thenReturn(obsBytes); - } - - @Before - public void setUp() throws IOException { - when(jwtMock.getClaim(PermissionAccessChecker.Factory.REALM_ACCESS_CLAIM)) - .thenReturn(claimMock); - when(jwtMock.getClaim(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM)) - .thenReturn(claimMock); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); - } - - protected AccessChecker getInstance() { - return new PermissionAccessChecker.Factory() - .create(jwtMock, null, fhirContext, PatientFinderImp.getInstance(fhirContext)); - } - - @Test - public void testManagePatientRoleCanAccessGetPatient() throws IOException { - // Query: GET/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); - when(claimMock.asMap()).thenReturn(map); - when(claimMock.asString()).thenReturn("ecbis-saa"); - - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testGetPatientRoleCanAccessGetPatient() throws IOException { - // Query: GET/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("GET_PATIENT")); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testGetPatientWithoutRoleCannotAccessGetPatient() throws IOException { - // Query: GET/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(false)); - } - - @Test - public void testDeletePatientRoleCanAccessDeletePatient() throws IOException { - // Query: DELETE/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("DELETE_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testManagePatientRoleCanAccessDeletePatient() throws IOException { - // Query: DELETE/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testDeletePatientWithoutRoleCannotAccessDeletePatient() throws IOException { - // Query: DELETE/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(false)); - } - - @Test - public void testPutWithManagePatientRoleCanAccessPutPatient() throws IOException { - // Query: PUT/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getResourceName()).thenReturn("Patient"); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.PUT); - - AccessChecker testInstance = getInstance(); - assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); - } - - @Test - public void testPutPatientWithRoleCanAccessPutPatient() throws IOException { - // Query: PUT/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getResourceName()).thenReturn("Patient"); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.PUT); - - AccessChecker testInstance = getInstance(); - assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); - } - - @Test - public void testPutPatientWithoutRoleCannotAccessPutPatient() throws IOException { - // Query: PUT/PID - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getResourceName()).thenReturn("Patient"); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.PUT); - - AccessChecker testInstance = getInstance(); - assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); - } - - @Test - public void testPostPatientWithRoleCanAccessPostPatient() throws IOException { - // Query: /POST - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("POST_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getResourceName()).thenReturn("Patient"); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); - } - - @Test - public void testPostPatientWithoutRoleCannotAccessPostPatient() throws IOException { - // Query: /POST - setUpFhirBundle("test_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); - when(requestMock.getResourceName()).thenReturn("Patient"); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); - } - - @Test - public void testManageResourceRoleCanAccessBundlePutResources() throws IOException { - setUpFhirBundle("bundle_transaction_put_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testPutResourceRoleCanAccessBundlePutResources() throws IOException { - setUpFhirBundle("bundle_transaction_put_patient.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testDeleteResourceRoleCanAccessBundleDeleteResources() throws IOException { - setUpFhirBundle("bundle_transaction_delete.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("DELETE_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testWithCorrectRolesCanAccessDifferentTypeBundleResources() throws IOException { - setUpFhirBundle("bundle_transaction_patient_and_non_patients.json"); - - Map map = new HashMap<>(); - map.put( - PermissionAccessChecker.Factory.ROLES, - Arrays.asList("PUT_PATIENT", "PUT_OBSERVATION", "PUT_ENCOUNTER")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testManageResourcesCanAccessDifferentTypeBundleResources() throws IOException { - setUpFhirBundle("bundle_transaction_patient_and_non_patients.json"); - - Map map = new HashMap<>(); - map.put( - PermissionAccessChecker.Factory.ROLES, - Arrays.asList("MANAGE_PATIENT", "MANAGE_OBSERVATION", "MANAGE_ENCOUNTER")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testManageResourcesWithMissingRoleCannotAccessDifferentTypeBundleResources() - throws IOException { - setUpFhirBundle("bundle_transaction_patient_and_non_patients.json"); - - Map map = new HashMap<>(); - map.put( - PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT", "MANAGE_ENCOUNTER")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - AccessChecker testInstance = getInstance(); - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(false)); - } - - @Test(expected = InvalidRequestException.class) - public void testBundleResourceNonTransactionTypeThrowsException() throws IOException { - setUpFhirBundle("bundle_empty.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList()); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - AccessChecker testInstance = getInstance(); - Assert.assertFalse(testInstance.checkAccess(requestMock).canAccess()); - } - - @Test - public void testAccessGrantedWhenManageResourcePresentForTypeBundleResources() - throws IOException { - setUpFhirBundle("test_bundle_transaction.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - PermissionAccessChecker testInstance = Mockito.spy((PermissionAccessChecker) getInstance()); - when(testInstance.isDevMode()).thenReturn(true); - - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testAccessGrantedWhenAllRolesPresentForTypeBundleResources() throws IOException { - setUpFhirBundle("test_bundle_transaction.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT", "POST_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - PermissionAccessChecker testInstance = Mockito.spy((PermissionAccessChecker) getInstance()); - when(testInstance.isDevMode()).thenReturn(true); - - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(true)); - } - - @Test - public void testAccessDeniedWhenSingleRoleMissingForTypeBundleResources() throws IOException { - setUpFhirBundle("test_bundle_transaction.json"); - - Map map = new HashMap<>(); - map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT")); - map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); - when(claimMock.asMap()).thenReturn(map); - - when(requestMock.getResourceName()).thenReturn(null); - when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); - - PermissionAccessChecker testInstance = Mockito.spy((PermissionAccessChecker) getInstance()); - when(testInstance.isDevMode()).thenReturn(true); - - boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); - - assertThat(canAccess, equalTo(false)); - } -} diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java deleted file mode 100755 index 983b3ded..00000000 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java +++ /dev/null @@ -1,565 +0,0 @@ -/* - * Copyright 2021-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.fhir.gateway.plugin; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.rest.api.RequestTypeEnum; -import ca.uhn.fhir.rest.api.RestOperationTypeEnum; -import ca.uhn.fhir.rest.api.server.RequestDetails; -import ca.uhn.fhir.rest.client.api.IGenericClient; -import ca.uhn.fhir.rest.gclient.ITransaction; -import ca.uhn.fhir.rest.gclient.ITransactionTyped; -import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; -import com.google.common.collect.Maps; -import com.google.common.io.Resources; -import com.google.fhir.gateway.ProxyConstants; -import com.google.fhir.gateway.interfaces.RequestDetailsReader; -import com.google.fhir.gateway.interfaces.RequestMutation; -import java.io.IOException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpResponse; -import org.hl7.fhir.r4.model.Bundle; -import org.hl7.fhir.r4.model.ListResource; -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Answers; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; - -@RunWith(MockitoJUnitRunner.class) -public class SyncAccessDecisionTest { - - private List locationIds = new ArrayList<>(); - - private List careTeamIds = new ArrayList<>(); - - private List organisationIds = new ArrayList<>(); - - private List userRoles = new ArrayList<>(); - - private SyncAccessDecision testInstance; - - @Test - public void - preprocessShouldAddAllFiltersWhenIdsForLocationsOrganisationsAndCareTeamsAreProvided() { - locationIds.addAll(Arrays.asList("my-location-id", "my-location-id2")); - careTeamIds.add("my-careteam-id"); - organisationIds.add("my-organization-id"); - - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Patient"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); - requestDetails.setRequestPath("Patient"); - - // Call the method under testing - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - List allIds = new ArrayList<>(); - allIds.addAll(locationIds); - allIds.addAll(organisationIds); - allIds.addAll(careTeamIds); - - List locationTagToValuesList = new ArrayList<>(); - - for (String locationId : locationIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); - - locationTagToValuesList.add(ProxyConstants.LOCATION_TAG_URL + "|" + locationId); - } - - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .get(0) - .contains(StringUtils.join(locationTagToValuesList, ","))); - - List careteamTagToValuesList = new ArrayList<>(); - - for (String careTeamId : careTeamIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(careTeamId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(careTeamId)); - careteamTagToValuesList.add(ProxyConstants.LOCATION_TAG_URL + "|" + careTeamId); - } - - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .get(0) - .contains(StringUtils.join(locationTagToValuesList, ","))); - - for (String organisationId : organisationIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(organisationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(organisationId)); - } - - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .get(0) - .contains( - StringUtils.join( - organisationIds, "," + ProxyConstants.ORGANISATION_TAG_URL + "|"))); - } - - @Test - public void preProcessShouldAddLocationIdFiltersWhenUserIsAssignedToLocationsOnly() - throws IOException { - locationIds.add("locationid12"); - locationIds.add("locationid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Patient"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); - requestDetails.setRequestPath("Patient"); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - for (String locationId : locationIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); - } - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .get(0) - .contains(StringUtils.join(locationIds, "," + ProxyConstants.LOCATION_TAG_URL + "|"))); - - for (String param : mutatedRequest.getQueryParams().get("_tag")) { - Assert.assertFalse(param.contains(ProxyConstants.CARE_TEAM_TAG_URL)); - Assert.assertFalse(param.contains(ProxyConstants.ORGANISATION_TAG_URL)); - } - } - - @Test - public void preProcessShouldAddCareTeamIdFiltersWhenUserIsAssignedToCareTeamsOnly() - throws IOException { - careTeamIds.add("careteamid1"); - careTeamIds.add("careteamid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Patient"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); - requestDetails.setRequestPath("Patient"); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - for (String locationId : careTeamIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); - } - - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .get(0) - .contains(StringUtils.join(careTeamIds, "," + ProxyConstants.CARE_TEAM_TAG_URL + "|"))); - - for (String param : mutatedRequest.getQueryParams().get("_tag")) { - Assert.assertFalse(param.contains(ProxyConstants.LOCATION_TAG_URL)); - Assert.assertFalse(param.contains(ProxyConstants.ORGANISATION_TAG_URL)); - } - } - - @Test - public void preProcessShouldAddOrganisationIdFiltersWhenUserIsAssignedToOrganisationsOnly() - throws IOException { - organisationIds.add("organizationid1"); - organisationIds.add("organizationid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Patient"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); - requestDetails.setRequestPath("Patient"); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - for (String locationId : careTeamIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .contains(ProxyConstants.ORGANISATION_TAG_URL + "|" + locationId)); - } - - for (String param : mutatedRequest.getQueryParams().get("_tag")) { - Assert.assertFalse(param.contains(ProxyConstants.LOCATION_TAG_URL)); - Assert.assertFalse(param.contains(ProxyConstants.CARE_TEAM_TAG_URL)); - } - } - - @Test - public void preProcessShouldAddFiltersWhenResourceNotInSyncFilterIgnoredResourcesFile() { - organisationIds.add("organizationid1"); - organisationIds.add("organizationid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Patient"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); - requestDetails.setRequestPath("Patient"); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - for (String locationId : organisationIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); - Assert.assertEquals(1, mutatedRequest.getQueryParams().size()); - } - Assert.assertTrue( - mutatedRequest - .getQueryParams() - .get("_tag") - .get(0) - .contains( - StringUtils.join( - organisationIds, "," + ProxyConstants.ORGANISATION_TAG_URL + "|"))); - } - - @Test - public void preProcessShouldSkipAddingFiltersWhenResourceInSyncFilterIgnoredResourcesFile() { - organisationIds.add("organizationid1"); - organisationIds.add("organizationid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Questionnaire"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Questionnaire"); - requestDetails.setRequestPath("Questionnaire"); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - for (String locationId : organisationIds) { - Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); - Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); - Assert.assertNull(mutatedRequest); - } - } - - @Test - public void - preProcessShouldSkipAddingFiltersWhenSearchResourceByIdsInSyncFilterIgnoredResourcesFile() { - organisationIds.add("organizationid1"); - organisationIds.add("organizationid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("StructureMap"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - List queryStringParamValues = Arrays.asList("1000", "2000", "3000"); - requestDetails.setCompleteUrl( - "https://smartregister.org/fhir/StructureMap?_id=" - + StringUtils.join(queryStringParamValues, ",")); - Assert.assertEquals( - "https://smartregister.org/fhir/StructureMap?_id=1000,2000,3000", - requestDetails.getCompleteUrl()); - requestDetails.setRequestPath("StructureMap"); - - Map params = Maps.newHashMap(); - params.put("_id", new String[] {StringUtils.join(queryStringParamValues, ",")}); - requestDetails.setParameters(params); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - Assert.assertNull(mutatedRequest); - } - - @Test - public void - preProcessShouldAddFiltersWhenSearchResourceByIdsDoNotMatchSyncFilterIgnoredResources() { - organisationIds.add("organizationid1"); - organisationIds.add("organizationid2"); - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("StructureMap"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - List queryStringParamValues = Arrays.asList("1000", "2000"); - requestDetails.setCompleteUrl( - "https://smartregister.org/fhir/StructureMap?_id=" - + StringUtils.join(queryStringParamValues, ",")); - Assert.assertEquals( - "https://smartregister.org/fhir/StructureMap?_id=1000,2000", - requestDetails.getCompleteUrl()); - requestDetails.setRequestPath("StructureMap"); - - Map params = Maps.newHashMap(); - params.put("_id", new String[] {StringUtils.join(queryStringParamValues, ",")}); - requestDetails.setParameters(params); - - RequestMutation mutatedRequest = - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - - List searchParamArrays = - mutatedRequest.getQueryParams().get(ProxyConstants.TAG_SEARCH_PARAM); - Assert.assertNotNull(searchParamArrays); - - Assert.assertTrue( - searchParamArrays - .get(0) - .contains( - StringUtils.join( - organisationIds, "," + ProxyConstants.ORGANISATION_TAG_URL + "|"))); - } - - @Test(expected = RuntimeException.class) - public void preprocessShouldThrowRuntimeExceptionWhenNoSyncStrategyFilterIsProvided() { - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetails requestDetails = new ServletRequestDetails(); - requestDetails.setRequestType(RequestTypeEnum.GET); - requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); - requestDetails.setResourceName("Patient"); - requestDetails.setRequestPath("Patient"); - requestDetails.setFhirServerBase("https://smartregister.org/fhir"); - requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); - - // Call the method under testing - testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); - } - - @Test - public void testPostProcessWithListModeHeaderShouldFetchListEntriesBundle() throws IOException { - locationIds.add("Location-1"); - testInstance = Mockito.spy(createSyncAccessDecisionTestInstance()); - - FhirContext fhirR4Context = mock(FhirContext.class); - IGenericClient iGenericClient = mock(IGenericClient.class); - ITransaction iTransaction = mock(ITransaction.class); - ITransactionTyped iClientExecutable = mock(ITransactionTyped.class); - testInstance.setFhirR4Client(iGenericClient); - testInstance.setFhirR4Context(fhirR4Context); - - Mockito.when(iGenericClient.transaction()).thenReturn(iTransaction); - Mockito.when(iTransaction.withBundle(any(Bundle.class))).thenReturn(iClientExecutable); - - Bundle resultBundle = new Bundle(); - resultBundle.setType(Bundle.BundleType.BATCHRESPONSE); - resultBundle.setId("bundle-result-id"); - - Mockito.when(iClientExecutable.execute()).thenReturn(resultBundle); - - ArgumentCaptor bundleArgumentCaptor = ArgumentCaptor.forClass(Bundle.class); - - testInstance.setFhirR4Context(fhirR4Context); - - RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); - - Mockito.when(requestDetailsSpy.getHeader(SyncAccessDecision.Constants.FHIR_GATEWAY_MODE)) - .thenReturn(SyncAccessDecision.Constants.LIST_ENTRIES); - - URL listUrl = Resources.getResource("test_list_resource.json"); - String testListJson = Resources.toString(listUrl, StandardCharsets.UTF_8); - - HttpResponse fhirResponseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); - - TestUtil.setUpFhirResponseMock(fhirResponseMock, testListJson); - - String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); - - Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); - Bundle requestBundle = bundleArgumentCaptor.getValue(); - - // Verify modified request to the server - Assert.assertNotNull(requestBundle); - Assert.assertEquals(Bundle.BundleType.BATCH, requestBundle.getType()); - List requestBundleEntries = requestBundle.getEntry(); - Assert.assertEquals(2, requestBundleEntries.size()); - - Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); - Assert.assertEquals( - "Group/proxy-list-entry-id-1", requestBundleEntries.get(0).getRequest().getUrl()); - - Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(1).getRequest().getMethod()); - Assert.assertEquals( - "Group/proxy-list-entry-id-2", requestBundleEntries.get(1).getRequest().getUrl()); - - // Verify returned result content from the server request - Assert.assertNotNull(resultContent); - Assert.assertEquals( - "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\"}", - resultContent); - } - - @Test - public void testPostProcessWithoutListModeHeaderShouldShouldReturnNull() throws IOException { - testInstance = createSyncAccessDecisionTestInstance(); - - RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); - Mockito.when(requestDetailsSpy.getHeader(SyncAccessDecision.Constants.FHIR_GATEWAY_MODE)) - .thenReturn(""); - - String resultContent = - testInstance.postProcess(requestDetailsSpy, Mockito.mock(HttpResponse.class)); - - // Verify no special Post-Processing happened - Assert.assertNull(resultContent); - } - - @Test - public void testPostProcessWithListModeHeaderSearchByTagShouldFetchListEntriesBundle() - throws IOException { - locationIds.add("Location-1"); - testInstance = Mockito.spy(createSyncAccessDecisionTestInstance()); - - FhirContext fhirR4Context = mock(FhirContext.class); - IGenericClient iGenericClient = mock(IGenericClient.class); - ITransaction iTransaction = mock(ITransaction.class); - ITransactionTyped iClientExecutable = mock(ITransactionTyped.class); - - Mockito.when(iGenericClient.transaction()).thenReturn(iTransaction); - Mockito.when(iTransaction.withBundle(any(Bundle.class))).thenReturn(iClientExecutable); - - Bundle resultBundle = new Bundle(); - resultBundle.setType(Bundle.BundleType.BATCHRESPONSE); - resultBundle.setId("bundle-result-id"); - - Mockito.when(iClientExecutable.execute()).thenReturn(resultBundle); - - ArgumentCaptor bundleArgumentCaptor = ArgumentCaptor.forClass(Bundle.class); - - testInstance.setFhirR4Context(fhirR4Context); - - RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); - - Mockito.when(requestDetailsSpy.getHeader(SyncAccessDecision.Constants.FHIR_GATEWAY_MODE)) - .thenReturn(SyncAccessDecision.Constants.LIST_ENTRIES); - - URL listUrl = Resources.getResource("test_list_resource.json"); - String testListJson = Resources.toString(listUrl, StandardCharsets.UTF_8); - - FhirContext realFhirContext = FhirContext.forR4(); - ListResource listResource = - (ListResource) realFhirContext.newJsonParser().parseResource(testListJson); - - Bundle bundle = new Bundle(); - Bundle.BundleEntryComponent bundleEntryComponent = new Bundle.BundleEntryComponent(); - bundleEntryComponent.setResource(listResource); - bundle.setType(Bundle.BundleType.BATCHRESPONSE); - bundle.setEntry(Arrays.asList(bundleEntryComponent)); - - HttpResponse fhirResponseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); - - TestUtil.setUpFhirResponseMock( - fhirResponseMock, realFhirContext.newJsonParser().encodeResourceToString(bundle)); - - testInstance.setFhirR4Client(iGenericClient); - testInstance.setFhirR4Context(fhirR4Context); - String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); - - Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); - Bundle requestBundle = bundleArgumentCaptor.getValue(); - - // Verify modified request to the server - Assert.assertNotNull(requestBundle); - Assert.assertEquals(Bundle.BundleType.BATCH, requestBundle.getType()); - List requestBundleEntries = requestBundle.getEntry(); - Assert.assertEquals(2, requestBundleEntries.size()); - - Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); - Assert.assertEquals( - "Group/proxy-list-entry-id-1", requestBundleEntries.get(0).getRequest().getUrl()); - - Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(1).getRequest().getMethod()); - Assert.assertEquals( - "Group/proxy-list-entry-id-2", requestBundleEntries.get(1).getRequest().getUrl()); - - // Verify returned result content from the server request - Assert.assertNotNull(resultContent); - Assert.assertEquals( - "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\"}", - resultContent); - } - - @After - public void cleanUp() { - locationIds.clear(); - careTeamIds.clear(); - organisationIds.clear(); - } - - private SyncAccessDecision createSyncAccessDecisionTestInstance() { - SyncAccessDecision accessDecision = - new SyncAccessDecision( - "sample-keycloak-id", - "sample-application-id", - true, - locationIds, - careTeamIds, - organisationIds, - null, - userRoles); - - URL configFileUrl = Resources.getResource("hapi_sync_filter_ignored_queries.json"); - SyncAccessDecision.IgnoredResourcesConfig skippedDataFilterConfig = - accessDecision.getIgnoredResourcesConfigFileConfiguration(configFileUrl.getPath()); - accessDecision.setSkippedResourcesConfig(skippedDataFilterConfig); - return accessDecision; - } -} diff --git a/server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java b/server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java deleted file mode 100755 index c43cbb0c..00000000 --- a/server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2021-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.fhir.gateway; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.parser.IParser; -import ca.uhn.fhir.rest.api.RequestTypeEnum; -import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; -import com.google.fhir.gateway.interfaces.RequestDetailsReader; -import com.google.fhir.gateway.interfaces.ResourceFinder; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import org.hl7.fhir.instance.model.api.IBaseResource; -import org.hl7.fhir.r4.model.Bundle; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public final class ResourceFinderImp implements ResourceFinder { - private static final Logger logger = LoggerFactory.getLogger(ResourceFinderImp.class); - private static ResourceFinderImp instance = null; - private final FhirContext fhirContext; - - // This is supposed to be instantiated with getInstance method only. - private ResourceFinderImp(FhirContext fhirContext) { - this.fhirContext = fhirContext; - } - - private IBaseResource createResourceFromRequest(RequestDetailsReader request) { - byte[] requestContentBytes = request.loadRequestContents(); - Charset charset = request.getCharset(); - if (charset == null) { - charset = StandardCharsets.UTF_8; - } - String requestContent = new String(requestContentBytes, charset); - IParser jsonParser = fhirContext.newJsonParser(); - return jsonParser.parseResource(requestContent); - } - - @Override - public List findResourcesInBundle(RequestDetailsReader request) { - IBaseResource resource = createResourceFromRequest(request); - if (!(resource instanceof Bundle)) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "The provided resource is not a Bundle!", InvalidRequestException.class); - } - Bundle bundle = (Bundle) resource; - - if (bundle.getType() != Bundle.BundleType.TRANSACTION) { - // Currently, support only for transaction bundles - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Bundle type needs to be transaction!", InvalidRequestException.class); - } - - List requestTypeEnumList = new ArrayList<>(); - if (!bundle.hasEntry()) { - return requestTypeEnumList; - } - - for (Bundle.BundleEntryComponent entryComponent : bundle.getEntry()) { - Bundle.HTTPVerb httpMethod = entryComponent.getRequest().getMethod(); - if (httpMethod != Bundle.HTTPVerb.GET && !entryComponent.hasResource()) { - ExceptionUtil.throwRuntimeExceptionAndLog( - logger, "Bundle entry requires a resource field!", InvalidRequestException.class); - } - - requestTypeEnumList.add( - new BundleResources( - RequestTypeEnum.valueOf(httpMethod.name()), entryComponent.getResource())); - } - - return requestTypeEnumList; - } - - // A singleton instance of this class should be used, hence the constructor is private. - public static synchronized ResourceFinderImp getInstance(FhirContext fhirContext) { - if (instance != null) { - return instance; - } - - instance = new ResourceFinderImp(fhirContext); - return instance; - } -} diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java b/server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java deleted file mode 100755 index 7ea67781..00000000 --- a/server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2021-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.fhir.gateway.interfaces; - -import com.google.fhir.gateway.BundleResources; -import java.util.List; - -public interface ResourceFinder { - - List findResourcesInBundle(RequestDetailsReader request); -}