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 bd2b9bff..36c3c3ae 100644 --- a/exec/src/main/java/com/google/fhir/gateway/MainApp.java +++ b/exec/src/main/java/com/google/fhir/gateway/MainApp.java @@ -17,13 +17,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; 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"}) +@SpringBootApplication( + scanBasePackages = {"com.google.fhir.gateway.plugin"}, + exclude = {DataSourceAutoConfiguration.class}) @ServletComponentScan(basePackages = "com.google.fhir.gateway") public class MainApp { 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 e8aa5d41..eaba1246 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 @@ -17,6 +17,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.client.api.IGenericClient; import com.auth0.jwt.interfaces.DecodedJWT; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; @@ -35,6 +36,7 @@ import com.google.fhir.gateway.interfaces.NoOpAccessDecision; import com.google.fhir.gateway.interfaces.PatientFinder; import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import com.google.fhir.gateway.plugin.audit.BalpAccessDecision; import java.io.IOException; import java.util.Collections; import java.util.Objects; @@ -61,16 +63,19 @@ public class ListAccessChecker implements AccessChecker { private final String patientListId; private final PatientFinder patientFinder; private final Escaper PARAM_ESCAPER = UrlEscapers.urlFormParameterEscaper(); + private final BalpAccessDecision balpAccessDecision; private ListAccessChecker( HttpFhirClient httpFhirClient, String patientListId, FhirContext fhirContext, - PatientFinder patientFinder) { + PatientFinder patientFinder, + BalpAccessDecision balpAccessDecision) { this.fhirContext = fhirContext; this.httpFhirClient = httpFhirClient; this.patientListId = patientListId; this.patientFinder = patientFinder; + this.balpAccessDecision = balpAccessDecision; } /** @@ -103,6 +108,7 @@ private boolean listIncludesItems(String itemsParam) { } return false; } + // Note this returns true iff at least one of the patient IDs is found in the associated list. // The rationale is that a user should have access to a resource iff they are authorized to access // at least one of the patients referenced in that resource. This is a subjective decision, so we @@ -186,36 +192,41 @@ private AccessDecision processGet(RequestDetailsReader requestDetails) { // There should be a patient id in search params; the param name is based on the resource. if (FhirUtil.isSameResourceType(requestDetails.getResourceName(), ResourceType.List)) { if (patientListId.equals(FhirUtil.getIdOrNull(requestDetails))) { - return NoOpAccessDecision.accessGranted(); + return balpAccessDecision.withAccess(NoOpAccessDecision.accessGranted()); } return NoOpAccessDecision.accessDenied(); } Set patientIds = patientFinder.findPatientsFromParams(requestDetails); Set patientQueries = Sets.newHashSet(); patientIds.forEach(patientId -> patientQueries.add(String.format("Patient/%s", patientId))); - return new NoOpAccessDecision(serverListIncludesAllPatients(patientQueries)); + return balpAccessDecision.withAccess( + new NoOpAccessDecision(serverListIncludesAllPatients(patientQueries))); } private AccessDecision processPost(RequestDetailsReader requestDetails) { // We have decided to let clients add new patients while understanding its security risks. if (FhirUtil.isSameResourceType(requestDetails.getResourceName(), ResourceType.Patient)) { - return AccessGrantedAndUpdateList.forPatientResource( - patientListId, httpFhirClient, fhirContext); + return balpAccessDecision.withAccess( + AccessGrantedAndUpdateList.forPatientResource( + patientListId, httpFhirClient, fhirContext)); } Set patientIds = patientFinder.findPatientsInResource(requestDetails); - return new NoOpAccessDecision(serverListIncludesAnyPatient(patientIds)); + return balpAccessDecision.withAccess( + new NoOpAccessDecision(serverListIncludesAnyPatient(patientIds))); } private AccessDecision processPut(RequestDetailsReader requestDetails) throws IOException { if (FhirUtil.isSameResourceType(requestDetails.getResourceName(), ResourceType.Patient)) { AccessDecision accessDecision = checkPatientAccessInUpdate(requestDetails); if (accessDecision == null) { - return AccessGrantedAndUpdateList.forPatientResource( - patientListId, httpFhirClient, fhirContext); + return balpAccessDecision.withAccess( + AccessGrantedAndUpdateList.forPatientResource( + patientListId, httpFhirClient, fhirContext)); } - return accessDecision; + return balpAccessDecision.withAccess(accessDecision); } - return checkNonPatientAccessInUpdate(requestDetails, RequestTypeEnum.PUT); + return balpAccessDecision.withAccess( + checkNonPatientAccessInUpdate(requestDetails, RequestTypeEnum.PUT)); } private AccessDecision processPatch(RequestDetailsReader requestDetails) throws IOException { @@ -225,9 +236,10 @@ private AccessDecision processPatch(RequestDetailsReader requestDetails) throws logger.error("Creating a new Patient via PATCH is not allowed"); return NoOpAccessDecision.accessDenied(); } - return accessDecision; + return balpAccessDecision.withAccess(accessDecision); } - return checkNonPatientAccessInUpdate(requestDetails, RequestTypeEnum.PATCH); + return balpAccessDecision.withAccess( + checkNonPatientAccessInUpdate(requestDetails, RequestTypeEnum.PATCH)); } private AccessDecision processDelete(RequestDetailsReader requestDetails) { @@ -243,7 +255,8 @@ private AccessDecision processDelete(RequestDetailsReader requestDetails) { Set patientIds = patientFinder.findPatientsFromParams(requestDetails); Set patientQueries = Sets.newHashSet(); patientIds.forEach(patientId -> patientQueries.add(String.format("Patient/%s", patientId))); - return new NoOpAccessDecision(serverListIncludesAllPatients(patientQueries)); + return balpAccessDecision.withAccess( + new NoOpAccessDecision(serverListIncludesAllPatients(patientQueries))); } private AccessDecision checkNonPatientAccessInUpdate( @@ -304,7 +317,7 @@ private AccessDecision processBundle(RequestDetailsReader requestDetails) throws Set putPatientIds = patientRequestsInBundle.getUpdatedPatients(); if (!createPatients && putPatientIds.isEmpty()) { - return NoOpAccessDecision.accessGranted(); + return balpAccessDecision.withAccess(NoOpAccessDecision.accessGranted()); } if (putPatientIds.isEmpty()) { @@ -400,7 +413,15 @@ public AccessChecker create( FhirContext fhirContext, PatientFinder patientFinder) { String patientListId = getListId(jwt); - return new ListAccessChecker(httpFhirClient, patientListId, fhirContext, patientFinder); + + // Assumes Audit repository FHIR Server is using same HAPI FHIR version (otherwise we need to + // canonicalize) + IGenericClient genericClient = + fhirContext.newRestfulGenericClient(httpFhirClient.getBaseUrl()); + + BalpAccessDecision balpAccessDecision = new BalpAccessDecision(genericClient); + return new ListAccessChecker( + httpFhirClient, patientListId, fhirContext, patientFinder, balpAccessDecision); } } } diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAccessDecision.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAccessDecision.java new file mode 100644 index 00000000..a1d2e739 --- /dev/null +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAccessDecision.java @@ -0,0 +1,54 @@ +package com.google.fhir.gateway.plugin.audit; + +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditContextServices; +import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditEventSink; +import com.google.fhir.gateway.interfaces.AccessDecision; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import com.google.fhir.gateway.interfaces.RequestMutation; +import java.io.IOException; +import org.apache.http.HttpResponse; +import org.jetbrains.annotations.Nullable; + +public class BalpAccessDecision implements AccessDecision { + + private final BalpAuditEventSink eventSink; + private final IBalpAuditContextServices contextServices; + private AccessDecision accessDecision; + + public BalpAccessDecision(IGenericClient genericClient) { + this.eventSink = new BalpAuditEventSink(genericClient); + this.contextServices = new BalpAuditContextService(); + } + + @Override + public boolean canAccess() { + return accessDecision.canAccess(); + } + + @Override + public @Nullable RequestMutation getRequestMutation(RequestDetailsReader requestDetailsReader) { + return accessDecision.getRequestMutation(requestDetailsReader); + } + + @Override + public String postProcess(RequestDetailsReader request, HttpResponse response) + throws IOException { + return accessDecision.postProcess(request, response); + } + + @Override + public @Nullable IBalpAuditEventSink getBalpAuditEventSink() { + return this.eventSink; + } + + @Override + public @Nullable IBalpAuditContextServices getBalpAuditContextService() { + return this.contextServices; + } + + public BalpAccessDecision withAccess(AccessDecision accessDecision) { + this.accessDecision = accessDecision; + return this; + } +} diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAuditContextService.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAuditContextService.java new file mode 100644 index 00000000..11759d53 --- /dev/null +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAuditContextService.java @@ -0,0 +1,86 @@ +package com.google.fhir.gateway.plugin.audit; + +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; +import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditContextServices; +import com.auth0.jwt.JWT; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.google.fhir.gateway.JwtUtil; +import jakarta.annotation.Nonnull; +import org.apache.http.HttpHeaders; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Reference; +import org.jetbrains.annotations.NotNull; + +public class BalpAuditContextService implements IBalpAuditContextServices { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String CLAIM_NAME = "name"; + private static final String CLAIM_PREFERRED_NAME = "preferred_username"; + private static final String CLAIM_SUBJECT = "sub"; + + @Override + public @NotNull Reference getAgentClientWho(RequestDetails requestDetails) { + + return new Reference() + // .setReference("Device/fhir-info-gateway") + .setType("Device") + .setDisplay("FHIR Info Gateway") + .setIdentifier( + new Identifier() + .setSystem("http://fhir-info-gateway/devices") + .setValue("fhir-info-gateway-001")); + } + + @Override + public @NotNull Reference getAgentUserWho(RequestDetails requestDetails) { + + String username = getClaimIfExists(requestDetails, CLAIM_PREFERRED_NAME); + String name = getClaimIfExists(requestDetails, CLAIM_NAME); + String subject = getClaimIfExists(requestDetails, CLAIM_SUBJECT); + + return new Reference() + // .setReference("Practitioner/" + subject) + .setType("Practitioner") + .setDisplay(name) + .setIdentifier( + new Identifier() + .setSystem("http://fhir-info-gateway/practitioners") + .setValue(username)); + } + + @Override + public @NotNull String massageResourceIdForStorage( + @Nonnull RequestDetails theRequestDetails, + @Nonnull IBaseResource theResource, + @Nonnull IIdType theResourceId) { + + /** + * Server not configured to allow external references resulting to InvalidRequestException: HTTP + * 400 : HAPI-0507: Resource contains external reference to URL. Here we should use relative + * references instead e.g. Patient/123; + */ + // String serverBaseUrl = theRequestDetails.getFhirServerBase(); + return theRequestDetails.getId() != null + ? theRequestDetails.getId().getValue() + : ""; // For entity POST there will be no agent.who entry reference since not generated yet + } + + public String getClaimIfExists(RequestDetails requestDetails, String claimName) { + String claim; + try { + String authHeader = requestDetails.getHeader(HttpHeaders.AUTHORIZATION); + String bearerToken = authHeader.substring(BEARER_PREFIX.length()); + DecodedJWT jwt; + + jwt = JWT.decode(bearerToken); + claim = JwtUtil.getClaimOrDie(jwt, claimName); + } catch (JWTDecodeException | AuthenticationException e) { + claim = ""; + } + return claim; + } +} diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAuditEventSink.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAuditEventSink.java new file mode 100644 index 00000000..17abbf84 --- /dev/null +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/audit/BalpAuditEventSink.java @@ -0,0 +1,19 @@ +package com.google.fhir.gateway.plugin.audit; + +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditEventSink; +import org.hl7.fhir.r4.model.AuditEvent; + +public class BalpAuditEventSink implements IBalpAuditEventSink { + + private final IGenericClient genericClient; + + public BalpAuditEventSink(IGenericClient genericClient) { + this.genericClient = genericClient; + } + + @Override + public void recordAuditEvent(AuditEvent auditEvent) { + genericClient.create().resource(auditEvent).prettyPrint().encodedJson().execute(); + } +} diff --git a/pom.xml b/pom.xml index 0cae833e..21bba527 100644 --- a/pom.xml +++ b/pom.xml @@ -266,7 +266,7 @@ - 1.15.0 + 1.17.0 true diff --git a/server/pom.xml b/server/pom.xml index 0e2faa0a..85eee0d9 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -67,6 +67,13 @@ ${hapifhir_version} + + + ca.uhn.hapi.fhir + hapi-fhir-storage + 8.0.0 + + jakarta.servlet 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 f96d6afc..dbb9f671 100755 --- a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java +++ b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java @@ -20,12 +20,15 @@ import ca.uhn.fhir.interceptor.api.Interceptor; import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.IRestfulResponse; import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.client.api.IGenericClient; 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.storage.interceptor.balp.BalpAuditCaptureInterceptor; import com.auth0.jwt.interfaces.DecodedJWT; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; @@ -38,11 +41,14 @@ import java.io.Reader; import java.io.StringReader; import java.io.Writer; +import java.nio.charset.StandardCharsets; import java.util.Locale; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.DomainResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.CollectionUtils; @@ -158,6 +164,37 @@ public boolean authorizeRequest(RequestDetails requestDetails) { String content = null; if (HttpUtil.isResponseValid(response)) { try { + // Request was successful so record Audit Event + + if (outcome.getBalpAuditEventSink() != null + && outcome.getBalpAuditContextService() != null) { + + BalpAuditCaptureInterceptor balpInterceptor = + new BalpAuditCaptureInterceptor( + outcome.getBalpAuditEventSink(), outcome.getBalpAuditContextService()); + + // PoC - for now processing non-Bundle requests only + switch (requestDetails.getRequestType()) { + case POST: + servletDetails.setRestOperationType(RestOperationTypeEnum.CREATE); + balpInterceptor.hookStoragePrecommitResourceCreated( + this.getResource(requestDetails), servletDetails); + break; + case PUT: + servletDetails.setRestOperationType(RestOperationTypeEnum.UPDATE); + IBaseResource oldResource = this.fetchStoredResource(requestDetails); + balpInterceptor.hookStoragePrecommitResourceUpdated( + oldResource, this.getResource(requestDetails), servletDetails); + break; + case DELETE: + servletDetails.setRestOperationType(RestOperationTypeEnum.DELETE); + balpInterceptor.hookStoragePrecommitResourceDeleted( + this.getResource(requestDetails), servletDetails); + break; + default: + break; + } + } // For post-processing rationale/example see b/207589782#comment3. content = outcome.postProcess(new RequestDetailsToReader(requestDetails), response); } catch (Exception e) { @@ -205,6 +242,47 @@ public boolean authorizeRequest(RequestDetails requestDetails) { return false; } + private IBaseResource getResource(RequestDetails requestDetails) { + String jsonRequestBody = + new String(requestDetails.getRequestContentsIfLoaded(), StandardCharsets.UTF_8); + IBaseResource resource = server.getFhirContext().newJsonParser().parseResource(jsonRequestBody); + + if (resource instanceof DomainResource) { + return resource; + } + return null; + } + + private IBaseResource fetchStoredResource(RequestDetails requestDetails) { + IGenericClient genericClient = + server.getFhirContext().newRestfulGenericClient(fhirClient.getBaseUrl()); + IBaseResource current = + genericClient + .read() + .resource( + server + .getFhirContext() + .getResourceDefinition(requestDetails.getResourceName()) + .getImplementingClass()) + .withId(requestDetails.getId()) + .execute(); + String currentVersion = current.getMeta().getVersionId(); + int prevVersion = Integer.parseInt(currentVersion) - 1; + + IBaseResource previous = + genericClient + .read() + .resource( + server + .getFhirContext() + .getResourceDefinition(requestDetails.getResourceName()) + .getImplementingClass()) + .withIdAndVersion(requestDetails.getId().getIdPart(), String.valueOf(prevVersion)) + .execute(); + + return previous; + } + private boolean sendGzippedResponse(ServletRequestDetails requestDetails) { // we send gzipped encoded response to client only if they requested so String acceptEncodingValue = requestDetails.getHeader(ACCEPT_ENCODING_HEADER.toLowerCase()); diff --git a/server/src/main/java/com/google/fhir/gateway/GcpFhirClient.java b/server/src/main/java/com/google/fhir/gateway/GcpFhirClient.java index 31d7ff04..846df1d4 100644 --- a/server/src/main/java/com/google/fhir/gateway/GcpFhirClient.java +++ b/server/src/main/java/com/google/fhir/gateway/GcpFhirClient.java @@ -47,7 +47,7 @@ public GcpFhirClient(String gcpFhirStore, GoogleCredentials credentials) throws } @Override - protected String getBaseUrl() { + public String getBaseUrl() { return gcpFhirStore; } 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 ee9071d7..cf8de0b9 100644 --- a/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java +++ b/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java @@ -36,7 +36,7 @@ private GenericFhirClient(String genericFhirStore) { } @Override - protected String getBaseUrl() { + public String getBaseUrl() { return genericFhirStore; } 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 d9e46167..dd5039d5 100644 --- a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java +++ b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java @@ -87,7 +87,7 @@ public abstract class HttpFhirClient { "x-forwarded-for", "x-forwarded-host"); - protected abstract String getBaseUrl(); + public abstract String getBaseUrl(); protected abstract URI getUriForResource(String resourcePath) throws URISyntaxException; 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 48df4adb..604f2b55 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 @@ -15,6 +15,8 @@ */ package com.google.fhir.gateway.interfaces; +import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditContextServices; +import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditEventSink; import java.io.IOException; import javax.annotation.Nullable; import org.apache.http.HttpResponse; @@ -55,4 +57,14 @@ public interface AccessDecision { * content in memory whenever it is not needed for post-processing. */ String postProcess(RequestDetailsReader request, HttpResponse response) throws IOException; + + @Nullable + default IBalpAuditEventSink getBalpAuditEventSink() { + return null; + } + + @Nullable + default IBalpAuditContextServices getBalpAuditContextService() { + return null; + } }