Add BasicAuditEventLogging (BALP) Interceptor (#4787)

* Begin work on BALP interceptor

* Work on BALP

* Basic profile

* Work on BALP

* Add BALP

* Work on balp

* Add update and delete

* Work on BALP

* Add logging

* Tests

* Modify test server

* Work on docs

* Add documentation

* Add changelog

* Fix #4728 - Typos in docs

* Test fixes

* Move changelog

* Update hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/BalpAuditCaptureInterceptor.java

Co-authored-by: michaelabuckley <michaelabuckley@gmail.com>

* Resolve PR comments

* Test fixes

---------

Co-authored-by: michaelabuckley <michaelabuckley@gmail.com>
This commit is contained in:
James Agnew 2023-05-03 11:46:22 -04:00 committed by GitHub
parent f4c0f6e9fc
commit a3c33d2a53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2827 additions and 101 deletions

View File

@ -760,6 +760,76 @@ public class FhirTerser {
} }
} }
class CompartmentOwnerVisitor implements ICompartmentOwnerVisitor {
private final String myWantRef;
public boolean isFound() {
return myFound;
}
private boolean myFound;
public CompartmentOwnerVisitor(String theWantRef) {
myWantRef = theWantRef;
}
@Override
public boolean consume(IIdType theCompartmentOwner) {
if (myWantRef.equals(theCompartmentOwner.toUnqualifiedVersionless().getValue())) {
myFound = true;
}
return !myFound;
}
}
CompartmentOwnerVisitor consumer = new CompartmentOwnerVisitor(wantRef);
visitCompartmentOwnersForResource(theCompartmentName, theSource, theAdditionalCompartmentParamNames, consumer);
return consumer.isFound();
}
/**
* Returns the owners of the compartment in <code>theSource</code> is in the compartment named <code>theCompartmentName</code>.
*
* @param theCompartmentName The name of the compartment
* @param theSource The potential member of the compartment
* @param theAdditionalCompartmentParamNames If provided, search param names provided here will be considered as included in the given compartment for this comparison.
*/
@Nonnull
public List<IIdType> getCompartmentOwnersForResource(String theCompartmentName, IBaseResource theSource, Set<String> theAdditionalCompartmentParamNames) {
Validate.notBlank(theCompartmentName, "theCompartmentName must not be null or blank");
Validate.notNull(theSource, "theSource must not be null");
class CompartmentOwnerVisitor implements ICompartmentOwnerVisitor {
private final Set<String> myOwnersAdded = new HashSet<>();
private final List<IIdType> myOwners = new ArrayList<>(2);
public List<IIdType> getOwners() {
return myOwners;
}
@Override
public boolean consume(IIdType theCompartmentOwner) {
if (myOwnersAdded.add(theCompartmentOwner.getValue())) {
myOwners.add(theCompartmentOwner);
}
return true;
}
}
CompartmentOwnerVisitor consumer = new CompartmentOwnerVisitor();
visitCompartmentOwnersForResource(theCompartmentName, theSource, theAdditionalCompartmentParamNames, consumer);
return consumer.getOwners();
}
private void visitCompartmentOwnersForResource(String theCompartmentName, IBaseResource theSource, Set<String> theAdditionalCompartmentParamNames, ICompartmentOwnerVisitor theConsumer) {
Validate.notBlank(theCompartmentName, "theCompartmentName must not be null or blank");
Validate.notNull(theSource, "theSource must not be null");
RuntimeResourceDefinition sourceDef = myContext.getResourceDefinition(theSource);
List<RuntimeSearchParam> params = sourceDef.getSearchParamsForCompartmentName(theCompartmentName); List<RuntimeSearchParam> params = sourceDef.getSearchParamsForCompartmentName(theCompartmentName);
// If passed an additional set of searchparameter names, add them for comparison purposes. // If passed an additional set of searchparameter names, add them for comparison purposes.
@ -777,7 +847,6 @@ public class FhirTerser {
} }
} }
for (RuntimeSearchParam nextParam : params) { for (RuntimeSearchParam nextParam : params) {
for (String nextPath : nextParam.getPathsSplit()) { for (String nextPath : nextParam.getPathsSplit()) {
@ -799,22 +868,20 @@ public class FhirTerser {
List<IBaseReference> values = getValues(theSource, nextPath, IBaseReference.class); List<IBaseReference> values = getValues(theSource, nextPath, IBaseReference.class);
for (IBaseReference nextValue : values) { for (IBaseReference nextValue : values) {
IIdType nextTargetId = nextValue.getReferenceElement(); IIdType nextTargetId = nextValue.getReferenceElement().toUnqualifiedVersionless();
String nextRef = nextTargetId.toUnqualifiedVersionless().getValue();
/* /*
* If the reference isn't an explicit resource ID, but instead is just * If the reference isn't an explicit resource ID, but instead is just
* a resource object, we'll calculate its ID and treat the target * a resource object, we'll calculate its ID and treat the target
* as that. * as that.
*/ */
if (isBlank(nextRef) && nextValue.getResource() != null) { if (isBlank(nextTargetId.getValue()) && nextValue.getResource() != null) {
IBaseResource nextTarget = nextValue.getResource(); IBaseResource nextTarget = nextValue.getResource();
nextTargetId = nextTarget.getIdElement().toUnqualifiedVersionless(); nextTargetId = nextTarget.getIdElement().toUnqualifiedVersionless();
if (!nextTargetId.hasResourceType()) { if (!nextTargetId.hasResourceType()) {
String resourceType = myContext.getResourceType(nextTarget); String resourceType = myContext.getResourceType(nextTarget);
nextTargetId.setParts(null, resourceType, nextTargetId.getIdPart(), null); nextTargetId.setParts(null, resourceType, nextTargetId.getIdPart(), null);
} }
nextRef = nextTargetId.getValue();
} }
if (isNotBlank(wantType)) { if (isNotBlank(wantType)) {
@ -824,14 +891,18 @@ public class FhirTerser {
} }
} }
if (wantRef.equals(nextRef)) { if (isNotBlank(nextTargetId.getValue())) {
return true; boolean shouldContinue = theConsumer.consume(nextTargetId);
if (!shouldContinue) {
return;
}
} }
} }
} }
} }
return false;
} }
private void visit(IBase theElement, BaseRuntimeChildDefinition theChildDefinition, BaseRuntimeElementDefinition<?> theDefinition, IModelVisitor2 theCallback, List<IBase> theContainingElementPath, private void visit(IBase theElement, BaseRuntimeChildDefinition theChildDefinition, BaseRuntimeElementDefinition<?> theDefinition, IModelVisitor2 theCallback, List<IBase> theContainingElementPath,
@ -1376,7 +1447,6 @@ public class FhirTerser {
return value; return value;
} }
/** /**
* Adds and returns a new element at the given path within the given structure. The paths used here * Adds and returns a new element at the given path within the given structure. The paths used here
* are <b>not FHIRPath expressions</b> but instead just simple dot-separated path expressions. * are <b>not FHIRPath expressions</b> but instead just simple dot-separated path expressions.
@ -1407,7 +1477,6 @@ public class FhirTerser {
return value; return value;
} }
/** /**
* This method has the same semantics as {@link #addElement(IBase, String, String)} but adds * This method has the same semantics as {@link #addElement(IBase, String, String)} but adds
* a collection of primitives instead of a single one. * a collection of primitives instead of a single one.
@ -1452,7 +1521,6 @@ public class FhirTerser {
return target; return target;
} }
public enum OptionsEnum { public enum OptionsEnum {
/** /**
@ -1468,6 +1536,17 @@ public class FhirTerser {
STORE_AND_REUSE_RESULTS STORE_AND_REUSE_RESULTS
} }
@FunctionalInterface
private interface ICompartmentOwnerVisitor {
/**
* @return Returns true if we should keep looking for more
*/
boolean consume(IIdType theCompartmentOwner);
}
public static class ContainedResources { public static class ContainedResources {
private long myNextContainedId = 1; private long myNextContainedId = 1;

View File

@ -50,6 +50,7 @@ import org.hl7.fhir.instance.model.api.IBaseConformance;
import org.hl7.fhir.instance.model.api.IBaseDatatype; import org.hl7.fhir.instance.model.api.IBaseDatatype;
import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseParameters;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.AuditEvent;
import org.hl7.fhir.instance.model.api.IPrimitiveType; import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r4.model.CodeSystem; import org.hl7.fhir.r4.model.CodeSystem;
import org.hl7.fhir.r4.model.CodeableConcept; import org.hl7.fhir.r4.model.CodeableConcept;
@ -271,6 +272,10 @@ public class VersionCanonicalizer {
return myStrategy.codeSystemToValidatorCanonical(theResource); return myStrategy.codeSystemToValidatorCanonical(theResource);
} }
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
return myStrategy.auditEventFromCanonical(theResource);
}
@Nonnull @Nonnull
private List<String> extractNonStandardSearchParameterListAndClearSourceIfAnyArePresent(IBaseResource theSearchParameter, String theChildName) { private List<String> extractNonStandardSearchParameterListAndClearSourceIfAnyArePresent(IBaseResource theSearchParameter, String theChildName) {
@ -326,6 +331,10 @@ public class VersionCanonicalizer {
org.hl7.fhir.r5.model.CodeSystem codeSystemToValidatorCanonical(IBaseResource theResource); org.hl7.fhir.r5.model.CodeSystem codeSystemToValidatorCanonical(IBaseResource theResource);
IBaseResource searchParameterFromCanonical(SearchParameter theResource); IBaseResource searchParameterFromCanonical(SearchParameter theResource);
IBaseResource auditEventFromCanonical(AuditEvent theResource);
IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource); IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource);
} }
@ -478,6 +487,12 @@ public class VersionCanonicalizer {
return (org.hl7.fhir.r5.model.CodeSystem) VersionConvertorFactory_10_50.convertResource(reencoded, ADVISOR_10_50); return (org.hl7.fhir.r5.model.CodeSystem) VersionConvertorFactory_10_50.convertResource(reencoded, ADVISOR_10_50);
} }
@Override
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
Resource hl7Org = VersionConvertorFactory_10_40.convertResource(theResource, ADVISOR_10_40);
return reencodeFromHl7Org(hl7Org);
}
@Override @Override
public IBaseResource searchParameterFromCanonical(SearchParameter theResource) { public IBaseResource searchParameterFromCanonical(SearchParameter theResource) {
Resource resource = VersionConvertorFactory_10_50.convertResource(theResource, ADVISOR_10_50); Resource resource = VersionConvertorFactory_10_50.convertResource(theResource, ADVISOR_10_50);
@ -588,6 +603,11 @@ public class VersionCanonicalizer {
return VersionConvertorFactory_14_50.convertResource(theResource, ADVISOR_14_50); return VersionConvertorFactory_14_50.convertResource(theResource, ADVISOR_14_50);
} }
@Override
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
return VersionConvertorFactory_14_40.convertResource(theResource, ADVISOR_14_40);
}
@Override @Override
public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) { public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) {
return (IBaseConformance) VersionConvertorFactory_14_50.convertResource(theResource, ADVISOR_14_50); return (IBaseConformance) VersionConvertorFactory_14_50.convertResource(theResource, ADVISOR_14_50);
@ -676,6 +696,11 @@ public class VersionCanonicalizer {
return VersionConvertorFactory_30_50.convertResource(theResource, ADVISOR_30_50); return VersionConvertorFactory_30_50.convertResource(theResource, ADVISOR_30_50);
} }
@Override
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
return VersionConvertorFactory_30_40.convertResource(theResource, ADVISOR_30_40);
}
@Override @Override
public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) { public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) {
return (IBaseConformance) VersionConvertorFactory_30_50.convertResource(theResource, ADVISOR_30_50); return (IBaseConformance) VersionConvertorFactory_30_50.convertResource(theResource, ADVISOR_30_50);
@ -763,6 +788,11 @@ public class VersionCanonicalizer {
return VersionConvertorFactory_40_50.convertResource(theResource, ADVISOR_40_50); return VersionConvertorFactory_40_50.convertResource(theResource, ADVISOR_40_50);
} }
@Override
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
return theResource;
}
@Override @Override
public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) { public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) {
return (IBaseConformance) VersionConvertorFactory_40_50.convertResource(theResource, ADVISOR_40_50); return (IBaseConformance) VersionConvertorFactory_40_50.convertResource(theResource, ADVISOR_40_50);
@ -859,6 +889,12 @@ public class VersionCanonicalizer {
return VersionConvertorFactory_43_50.convertResource(theResource, ADVISOR_43_50); return VersionConvertorFactory_43_50.convertResource(theResource, ADVISOR_43_50);
} }
@Override
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
org.hl7.fhir.r5.model.AuditEvent r5 = (org.hl7.fhir.r5.model.AuditEvent) VersionConvertorFactory_40_50.convertResource(theResource, ADVISOR_40_50);
return VersionConvertorFactory_43_50.convertResource(r5, ADVISOR_43_50);
}
@Override @Override
public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) { public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) {
return (IBaseConformance) VersionConvertorFactory_43_50.convertResource(theResource, ADVISOR_43_50); return (IBaseConformance) VersionConvertorFactory_43_50.convertResource(theResource, ADVISOR_43_50);
@ -948,6 +984,11 @@ public class VersionCanonicalizer {
return theResource; return theResource;
} }
@Override
public IBaseResource auditEventFromCanonical(AuditEvent theResource) {
return VersionConvertorFactory_40_50.convertResource(theResource, ADVISOR_40_50);
}
@Override @Override
public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) { public IBaseConformance capabilityStatementFromCanonical(CapabilityStatement theResource) {
return theResource; return theResource;

View File

@ -0,0 +1,87 @@
package ca.uhn.hapi.fhir.docs;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.storage.interceptor.balp.AsyncMemoryQueueBackedFhirClientBalpSink;
import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditContextServices;
import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditEventSink;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.client.interceptor.BasicAuthInterceptor;
import ca.uhn.fhir.rest.server.RestfulServer;
import org.hl7.fhir.r4.model.Reference;
import javax.annotation.Nonnull;
import javax.servlet.ServletException;
import java.util.List;
public class BalpExample {
//START SNIPPET: contextService
public class ExampleBalpAuditContextServices implements IBalpAuditContextServices {
/**
* Here we are just hard-coding a simple display name. In a real implementation
* we should use the actual identity of the requesting client.
*/
@Nonnull
@Override
public Reference getAgentClientWho(RequestDetails theRequestDetails) {
Reference client = new Reference();
client.setDisplay("Growth Chart Application");
client.getIdentifier()
.setSystem("http://example.org/clients")
.setValue("growth_chart");
return client;
}
/**
* Here we are just hard-coding a simple display name. In a real implementation
* we should use the actual identity of the requesting user.
*/
@Nonnull
@Override
public Reference getAgentUserWho(RequestDetails theRequestDetails) {
Reference user = new Reference();
user.getIdentifier()
.setSystem("http://example.org/users")
.setValue("my_username");
return user;
}
}
//END SNIPPET: contextService
//START SNIPPET: server
public class MyServer extends RestfulServer {
/**
* Constructor
*/
public MyServer() {
super(FhirContext.forR4Cached());
}
@Override
protected void initialize() throws ServletException {
// Register your resource providers and other interceptors here...
/*
* Create our context sservices object
*/
IBalpAuditContextServices contextServices = new ExampleBalpAuditContextServices();
/*
* Create our event sink
*/
FhirContext fhirContext = FhirContext.forR4Cached();
String targetUrl = "http://my.fhir.server/baseR4";
List<Object> clientInterceptors = List.of(
// We'll register an auth interceptor against the sink FHIR client so that
// credentials get passed to the target server. Of course in a real implementation
// you should never hard code credentials like this.
new BasicAuthInterceptor("username", "password")
);
IBalpAuditEventSink eventSink = new AsyncMemoryQueueBackedFhirClientBalpSink(fhirContext, targetUrl, clientInterceptors);
}
}
//END SNIPPET: server
}

View File

@ -0,0 +1,6 @@
---
type: add
issue: 4787
title: "A new interceptor called BalpAuditCaptureInterceptor has been added. This interceptor
automatically generates AuditEvent resources in a HAPI FHIR server that are conformant with the
[IHE BasicAudit Log Patterns](https://profiles.ihe.net/ITI/BALP/) IG."

View File

@ -0,0 +1,8 @@
---
type: fix
issue: 4787
title: "HashMapResourceProvider invoked the `STORAGE_PRESTORAGE_xxx` and
`STORAGE_PRECOMMIT_xxx` pointcuts after storing the resource into memory. This
means that if any interceptors threw an exception in an attempt to abort the
transaction, this did not actually result in the resource storage being aborted.
This has been corrected."

View File

@ -105,6 +105,7 @@ page.security.authorization_interceptor=Authorization Interceptor
page.security.consent_interceptor=Consent Interceptor page.security.consent_interceptor=Consent Interceptor
page.security.search_narrowing_interceptor=Search Narrowing Interceptor page.security.search_narrowing_interceptor=Search Narrowing Interceptor
page.security.cors=CORS page.security.cors=CORS
page.security.balp_interceptor=Basic Audit Log Pattern (BALP)
section.validation.title=Validation section.validation.title=Validation
page.validation.introduction=Introduction page.validation.introduction=Introduction

View File

@ -173,10 +173,14 @@ The following example shows how to register this interceptor within a HAPI FHIR
HAPI FHIR includes an interceptor which can be used to implement CORS support on your server. See [Server CORS Documentation](/docs/security/cors.html#cors_interceptor) for information on how to use this interceptor. HAPI FHIR includes an interceptor which can be used to implement CORS support on your server. See [Server CORS Documentation](/docs/security/cors.html#cors_interceptor) for information on how to use this interceptor.
# Security: Audit
HAPI FHIR provides an interceptor that can be used to automatically generate and record AuditEvent resources based on user/client actions on the server. See [BALP Interceptor](../security/balp_interceptor.html) for more information.
# Security: Authorization # Security: Authorization
HAPI FHIR provides a powerful interceptor that can be used to implement user- and system-level authorization rules that are aware of FHIR semantics. See [Authorization](/docs/security/authorization_interceptor.html) for more information. HAPI FHIR provides an interceptor that can be used to implement user- and system-level authorization rules that are aware of FHIR semantics. See [Authorization](/docs/security/authorization_interceptor.html) for more information.
# Security: Consent # Security: Consent

View File

@ -100,7 +100,7 @@ Finally, use the [CustomThymeleafNarrativeGenerator](/hapi-fhir/apidocs/hapi-fhi
{{snippet:classpath:/ca/uhn/hapi/fhir/docs/NarrativeGenerator.java|gen}} {{snippet:classpath:/ca/uhn/hapi/fhir/docs/NarrativeGenerator.java|gen}}
``` ```
# Fragments Expressions in Thyemleaf Templates # Fragments Expressions in Thymeleaf Templates
Thymeleaf has a concept called Fragments, which allow reusable template portions that can be imported anywhere you need them. It can be helpful to put these fragment definitions in their own file. For example, the following property file declares a template and a fragment: Thymeleaf has a concept called Fragments, which allow reusable template portions that can be imported anywhere you need them. It can be helpful to put these fragment definitions in their own file. For example, the following property file declares a template and a fragment:
@ -121,7 +121,7 @@ And the following parent template (`narrative-with-fragment-parent.html`) import
``` ```
# FHIRPath Expressions in Thyemleaf Templates # FHIRPath Expressions in Thymeleaf Templates
Thymeleaf templates can incorporate FHIRPath expressions using the `#fhirpath` expression object. Thymeleaf templates can incorporate FHIRPath expressions using the `#fhirpath` expression object.

View File

@ -0,0 +1,181 @@
# Basic Audit Log Patterns (BALP) Interceptor
The IHE [Basic Audit Log Patterns](https://profiles.ihe.net/ITI/BALP/) implementation guide describes a set of workflows and data models for the creation of [AuditEvent](http://hl7.org/fhir/AuditEvent.html) resources based on user/client actions.
HAPI FHIR provides an interceptor that can be registered against a server, and will observe events on that server and automatically generate AuditEvent resources which are conformant to the appropriate profiles within the BALP specification.
This interceptor implements the following profiles:
<table class="table table-striped">
<thead>
<tr>
<th>BALP Profile</th>
<th>Trigger</th>
<th>Triggering Pointcut</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.Create.html">Create</a>
</td>
<td>
Performed when a resource has been created that is not a member of the Patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRECOMMIT_RESOURCE_CREATED">STORAGE_PRECOMMIT_RESOURCE_CREATED</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.PatientCreate.html">PatientCreate</a>
</td>
<td>
Performed when a resource has been created that is a member of the Patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRECOMMIT_RESOURCE_CREATED">STORAGE_PRECOMMIT_RESOURCE_CREATED</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.Read.html">Read</a>
</td>
<td>
Performed when a resource has been read that is not a member of the Patient compartment.
Note that
other extended operations which expose individual resource data may also trigger the creation of
an AuditEvent with this profile. For example, the <code>$diff</code> operation exposes data within
a resource, so it will also trigger this event.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRESHOW_RESOURCES">STORAGE_PRESHOW_RESOURCES</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.PatientRead.html">PatientRead</a>
</td>
<td>
Performed when a resource has been read that is a member of the Patient compartment.
Note that
other extended operations which expose individual resource data may also trigger the creation of
an AuditEvent with this profile. For example, the <code>$diff</code> operation exposes data within
a resource, so it will also trigger this event.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRESHOW_RESOURCES">STORAGE_PRESHOW_RESOURCES</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.Update.html">Update</a>
</td>
<td>
Performed when a resource has been updated that is not a member of the Patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRECOMMIT_RESOURCE_UPDATED">STORAGE_PRECOMMIT_RESOURCE_UPDATED</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.PatientUpdate.html">PatientUpdate</a>
</td>
<td>
Performed when a resource has been updated that is a member of the Patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRECOMMIT_RESOURCE_UPDATED">STORAGE_PRECOMMIT_RESOURCE_UPDATED</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.Delete.html">Delete</a>
</td>
<td>
Performed when a resource has been deleted that is not a member of the Patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRECOMMIT_RESOURCE_DELETED">STORAGE_PRECOMMIT_RESOURCE_DELETED</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.PatientDelete.html">PatientDelete</a>
</td>
<td>
Performed when a resource has been deleted that is a member of the Patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRECOMMIT_RESOURCE_DELETED">STORAGE_PRECOMMIT_RESOURCE_DELETED</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.Query.html">Query</a>
</td>
<td>
Performed when a non-patient-oriented search is performed. This refers to a search that is returning
data that is not in a specific patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRESHOW_RESOURCES">STORAGE_PRESHOW_RESOURCES</a>
</td>
</tr>
<tr>
<td>
<a href="https://profiles.ihe.net/ITI/BALP/StructureDefinition-IHE.BasicAudit.PatientQuery.html">PatientQuery</a>
</td>
<td>
Performed when a patient-oriented search is performed. This refers to a search that returns data in
a specific patient compartment.
</td>
<td>
<a href="https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/interceptor/api/Pointcut.html#STORAGE_PRESHOW_RESOURCES">STORAGE_PRESHOW_RESOURCES</a>
</td>
</tr>
</tbody>
</table>
# Architecture
The HAPI FHIR BALP infrastructure consists of the following components:
* The [BalpAuditCaptureInterceptor](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/BalpAuditCaptureInterceptor.html) is the primary interceptor, which you register against a HAPI FHIR [Plain Server](../server_plain/).
* The [IBalpAuditEventSink](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/IBalpAuditEventSink.html) is an interface which receives generated AuditEvents and processes them. Appropriate processing will depend on your use case, but could be storing them locally, transmitting them to a remote server, logging them to a syslog, or even selectively dropping them. See [Audit Event Sink](#audit-event-sink) below.
* The [IBalpAuditContextServices](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/IBalpAuditContextServices.html) is an interface which supplies context information for a given client action. When generating a BALP conformant AuditEvent resource, the BalpAuditCaptureInterceptor will automatically populate most of the AuditEvent with details such as the _entity_ (ie. the resource being accessed or modified) and the _server_ (the FHIR server being used to transmit or store the information). However, other information such as the agent and the user (ie. the FHIR client and the physical user) are not known to HAPI FHIR and must be supplied for each request. This interface supplies these details.
<a name="audit-event-sink"/>
# Audit Event Sink
The BALP [IBalpAuditEventSink](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/IBalpAuditEventSink.html) receives and handles generated audit events.
This interface is designed to support custom implementations, so you can absolutely create your own. HAPI FHIR ships with the following implementation:
* [AsyncMemoryQueueBackedFhirClientBalpSink](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/AsyncMemoryQueueBackedFhirClientBalpSink.html) uses an HTTP/REST FHIR client to transmit AuditEvents to a FHIR server endpoint. This can be a local or a remote endpoint, and can be a server with any version of FHIR. Messages are transmitted asynchronously using an in-memory queue.
If you create an implementation of this interface that you think would be useful to others, we would welcome community contributions!
<a name="audit-context-services"/>
# Audit Context Services
In order to use this interceptor, you must suply an instance of [IBalpAuditContextServices](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/interceptor/balp/IBalpAuditContextServices.html). This interface supplies the information about each request that the interceptor cannot determine on its own, such as the identity of the requesting user and the requesting client.
The implementation of this interface for the [public HAPI FHIR server](https://hapi.fhir.org) is available [here](https://github.com/hapifhir/hapi-fhir/blob/master/hapi-fhir-jpaserver-uhnfhirtest/src/main/java/ca/uhn/fhirtest/config/FhirTestBalpAuditContextServices.java).
# Example
The following example shows a simple implementation of the Context Services:
```java
{{snippet:classpath:/ca/uhn/hapi/fhir/docs/BalpExample.java|contextService}}
```
And the following example shows a HAPI FHIR Basic Server with the BALP interceptor wired in:
```java
{{snippet:classpath:/ca/uhn/hapi/fhir/docs/BalpExample.java|server}}
```

View File

@ -2,6 +2,7 @@ package ca.uhn.fhir.jpa.interceptor;
import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test; import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.api.server.IBundleProvider;
import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.Patient;
@ -13,6 +14,8 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
@ -153,7 +156,32 @@ public class ForceOffsetSearchModeInterceptorTest extends BaseResourceProviderR4
assertEquals(1, myCaptureQueriesListener.countCommits()); assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks()); assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink("next").getUrl(), containsString("Patient?_count=7&_offset=7&active=true")); assertThat(outcome.getLink(Constants.LINK_NEXT).getUrl(), containsString("Patient?_count=7&_offset=7&active=true"));
assertNull(outcome.getLink(Constants.LINK_PREVIOUS));
// Second page
myCaptureQueriesListener.clear();
outcome = myClient
.loadPage()
.next(outcome)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), containsInAnyOrder(
"Patient/A7", "Patient/A8", "Patient/A9"
));
myCaptureQueriesListener.logSelectQueries();
assertEquals(2, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '7'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink(Constants.LINK_NEXT).getUrl(), containsString("Patient?_count=7&_offset=14&active=true"));
assertThat(outcome.getLink(Constants.LINK_PREVIOUS).getUrl(), containsString("Patient?_count=7&_offset=0&active=true"));
} }

View File

@ -12,6 +12,7 @@ import ca.uhn.fhir.jpa.bulk.export.provider.BulkDataExportProvider;
import ca.uhn.fhir.jpa.delete.ThreadSafeResourceDeleterSvc; import ca.uhn.fhir.jpa.delete.ThreadSafeResourceDeleterSvc;
import ca.uhn.fhir.jpa.graphql.GraphQLProvider; import ca.uhn.fhir.jpa.graphql.GraphQLProvider;
import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor; import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor;
import ca.uhn.fhir.storage.interceptor.balp.BalpAuditCaptureInterceptor;
import ca.uhn.fhir.jpa.ips.provider.IpsOperationProvider; import ca.uhn.fhir.jpa.ips.provider.IpsOperationProvider;
import ca.uhn.fhir.jpa.provider.DiffProvider; import ca.uhn.fhir.jpa.provider.DiffProvider;
import ca.uhn.fhir.jpa.provider.JpaCapabilityStatementProvider; import ca.uhn.fhir.jpa.provider.JpaCapabilityStatementProvider;
@ -38,6 +39,7 @@ import ca.uhn.fhir.rest.server.interceptor.ResponseHighlighterInterceptor;
import ca.uhn.fhir.rest.server.provider.ResourceProviderFactory; import ca.uhn.fhir.rest.server.provider.ResourceProviderFactory;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import ca.uhn.fhirtest.config.SqlCaptureInterceptor; import ca.uhn.fhirtest.config.SqlCaptureInterceptor;
import ca.uhn.fhirtest.config.TestAuditConfig;
import ca.uhn.fhirtest.config.TestDstu2Config; import ca.uhn.fhirtest.config.TestDstu2Config;
import ca.uhn.fhirtest.config.TestDstu3Config; import ca.uhn.fhirtest.config.TestDstu3Config;
import ca.uhn.fhirtest.config.TestR4BConfig; import ca.uhn.fhirtest.config.TestR4BConfig;
@ -59,6 +61,7 @@ import java.util.List;
public class TestRestfulServer extends RestfulServer { public class TestRestfulServer extends RestfulServer {
public static final String FHIR_BASEURL_R5 = "fhir.baseurl.r5"; public static final String FHIR_BASEURL_R5 = "fhir.baseurl.r5";
public static final String FHIR_BASEURL_AUDIT = "fhir.baseurl.audit";
public static final String FHIR_BASEURL_R4 = "fhir.baseurl.r4"; public static final String FHIR_BASEURL_R4 = "fhir.baseurl.r4";
public static final String FHIR_BASEURL_R4B = "fhir.baseurl.r4b"; public static final String FHIR_BASEURL_R4B = "fhir.baseurl.r4b";
public static final String FHIR_BASEURL_DSTU2 = "fhir.baseurl.dstu2"; public static final String FHIR_BASEURL_DSTU2 = "fhir.baseurl.dstu2";
@ -113,12 +116,13 @@ public class TestRestfulServer extends RestfulServer {
myAppCtx.register(TestDstu2Config.class, WebsocketDispatcherConfig.class); myAppCtx.register(TestDstu2Config.class, WebsocketDispatcherConfig.class);
baseUrlProperty = FHIR_BASEURL_DSTU2; baseUrlProperty = FHIR_BASEURL_DSTU2;
myAppCtx.refresh(); myAppCtx.refresh();
setFhirContext(FhirContext.forDstu2Cached()); setFhirContext(myAppCtx.getBean(FhirContext.class));
beans = myAppCtx.getBean("myResourceProvidersDstu2", ResourceProviderFactory.class); beans = myAppCtx.getBean("myResourceProvidersDstu2", ResourceProviderFactory.class);
systemDao = myAppCtx.getBean("mySystemDaoDstu2", IFhirSystemDao.class); systemDao = myAppCtx.getBean("mySystemDaoDstu2", IFhirSystemDao.class);
etagSupport = ETagSupportEnum.ENABLED; etagSupport = ETagSupportEnum.ENABLED;
JpaConformanceProviderDstu2 confProvider = new JpaConformanceProviderDstu2(this, systemDao, myAppCtx.getBean(JpaStorageSettings.class)); JpaConformanceProviderDstu2 confProvider = new JpaConformanceProviderDstu2(this, systemDao, myAppCtx.getBean(JpaStorageSettings.class));
setServerConformanceProvider(confProvider); setServerConformanceProvider(confProvider);
registerInterceptor(myAppCtx.getBean(BalpAuditCaptureInterceptor.class));
break; break;
} }
case "DSTU3": { case "DSTU3": {
@ -128,7 +132,7 @@ public class TestRestfulServer extends RestfulServer {
myAppCtx.register(TestDstu3Config.class, WebsocketDispatcherConfig.class); myAppCtx.register(TestDstu3Config.class, WebsocketDispatcherConfig.class);
baseUrlProperty = FHIR_BASEURL_DSTU3; baseUrlProperty = FHIR_BASEURL_DSTU3;
myAppCtx.refresh(); myAppCtx.refresh();
setFhirContext(FhirContext.forDstu3Cached()); setFhirContext(myAppCtx.getBean(FhirContext.class));
beans = myAppCtx.getBean("myResourceProvidersDstu3", ResourceProviderFactory.class); beans = myAppCtx.getBean("myResourceProvidersDstu3", ResourceProviderFactory.class);
systemDao = myAppCtx.getBean("mySystemDaoDstu3", IFhirSystemDao.class); systemDao = myAppCtx.getBean("mySystemDaoDstu3", IFhirSystemDao.class);
etagSupport = ETagSupportEnum.ENABLED; etagSupport = ETagSupportEnum.ENABLED;
@ -136,6 +140,7 @@ public class TestRestfulServer extends RestfulServer {
setServerConformanceProvider(confProvider); setServerConformanceProvider(confProvider);
providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class)); providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class));
providers.add(myAppCtx.getBean(GraphQLProvider.class)); providers.add(myAppCtx.getBean(GraphQLProvider.class));
registerInterceptor(myAppCtx.getBean(BalpAuditCaptureInterceptor.class));
break; break;
} }
case "R4": { case "R4": {
@ -145,7 +150,7 @@ public class TestRestfulServer extends RestfulServer {
myAppCtx.register(TestR4Config.class, WebsocketDispatcherConfig.class); myAppCtx.register(TestR4Config.class, WebsocketDispatcherConfig.class);
baseUrlProperty = FHIR_BASEURL_R4; baseUrlProperty = FHIR_BASEURL_R4;
myAppCtx.refresh(); myAppCtx.refresh();
setFhirContext(FhirContext.forR4Cached()); setFhirContext(myAppCtx.getBean(FhirContext.class));
beans = myAppCtx.getBean("myResourceProvidersR4", ResourceProviderFactory.class); beans = myAppCtx.getBean("myResourceProvidersR4", ResourceProviderFactory.class);
systemDao = myAppCtx.getBean("mySystemDaoR4", IFhirSystemDao.class); systemDao = myAppCtx.getBean("mySystemDaoR4", IFhirSystemDao.class);
etagSupport = ETagSupportEnum.ENABLED; etagSupport = ETagSupportEnum.ENABLED;
@ -155,6 +160,7 @@ public class TestRestfulServer extends RestfulServer {
providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class)); providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class));
providers.add(myAppCtx.getBean(GraphQLProvider.class)); providers.add(myAppCtx.getBean(GraphQLProvider.class));
providers.add(myAppCtx.getBean(IpsOperationProvider.class)); providers.add(myAppCtx.getBean(IpsOperationProvider.class));
registerInterceptor(myAppCtx.getBean(BalpAuditCaptureInterceptor.class));
break; break;
} }
case "R4B": { case "R4B": {
@ -164,7 +170,7 @@ public class TestRestfulServer extends RestfulServer {
myAppCtx.register(TestR4BConfig.class); myAppCtx.register(TestR4BConfig.class);
baseUrlProperty = FHIR_BASEURL_R4B; baseUrlProperty = FHIR_BASEURL_R4B;
myAppCtx.refresh(); myAppCtx.refresh();
setFhirContext(FhirContext.forR4BCached()); setFhirContext(myAppCtx.getBean(FhirContext.class));
beans = myAppCtx.getBean("myResourceProvidersR4B", ResourceProviderFactory.class); beans = myAppCtx.getBean("myResourceProvidersR4B", ResourceProviderFactory.class);
systemDao = myAppCtx.getBean("mySystemDaoR4B", IFhirSystemDao.class); systemDao = myAppCtx.getBean("mySystemDaoR4B", IFhirSystemDao.class);
etagSupport = ETagSupportEnum.ENABLED; etagSupport = ETagSupportEnum.ENABLED;
@ -173,6 +179,7 @@ public class TestRestfulServer extends RestfulServer {
setServerConformanceProvider(confProvider); setServerConformanceProvider(confProvider);
providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class)); providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class));
providers.add(myAppCtx.getBean(GraphQLProvider.class)); providers.add(myAppCtx.getBean(GraphQLProvider.class));
registerInterceptor(myAppCtx.getBean(BalpAuditCaptureInterceptor.class));
break; break;
} }
case "R5": { case "R5": {
@ -182,7 +189,7 @@ public class TestRestfulServer extends RestfulServer {
myAppCtx.register(TestR5Config.class, WebsocketDispatcherConfig.class); myAppCtx.register(TestR5Config.class, WebsocketDispatcherConfig.class);
baseUrlProperty = FHIR_BASEURL_R5; baseUrlProperty = FHIR_BASEURL_R5;
myAppCtx.refresh(); myAppCtx.refresh();
setFhirContext(FhirContext.forR5()); setFhirContext(myAppCtx.getBean(FhirContext.class));
beans = myAppCtx.getBean("myResourceProvidersR5", ResourceProviderFactory.class); beans = myAppCtx.getBean("myResourceProvidersR5", ResourceProviderFactory.class);
systemDao = myAppCtx.getBean("mySystemDaoR5", IFhirSystemDao.class); systemDao = myAppCtx.getBean("mySystemDaoR5", IFhirSystemDao.class);
etagSupport = ETagSupportEnum.ENABLED; etagSupport = ETagSupportEnum.ENABLED;
@ -191,6 +198,23 @@ public class TestRestfulServer extends RestfulServer {
setServerConformanceProvider(confProvider); setServerConformanceProvider(confProvider);
providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class)); providers.add(myAppCtx.getBean(TerminologyUploaderProvider.class));
providers.add(myAppCtx.getBean(GraphQLProvider.class)); providers.add(myAppCtx.getBean(GraphQLProvider.class));
registerInterceptor(myAppCtx.getBean(BalpAuditCaptureInterceptor.class));
break;
}
case "AUDIT": {
myAppCtx = new AnnotationConfigWebApplicationContext();
myAppCtx.setServletConfig(getServletConfig());
myAppCtx.setParent(parentAppCtx);
myAppCtx.register(TestAuditConfig.class);
baseUrlProperty = FHIR_BASEURL_AUDIT;
myAppCtx.refresh();
setFhirContext(myAppCtx.getBean(FhirContext.class));
beans = myAppCtx.getBean("myResourceProvidersR4", ResourceProviderFactory.class);
systemDao = myAppCtx.getBean("mySystemDaoR4", IFhirSystemDao.class);
etagSupport = ETagSupportEnum.ENABLED;
IValidationSupport validationSupport = myAppCtx.getBean(IValidationSupport.class);
JpaCapabilityStatementProvider confProvider = new JpaCapabilityStatementProvider(this, systemDao, myAppCtx.getBean(JpaStorageSettings.class), myAppCtx.getBean(ISearchParamRegistry.class), validationSupport);
setServerConformanceProvider(confProvider);
break; break;
} }
default: default:

View File

@ -1,10 +1,15 @@
package ca.uhn.fhirtest.config; package ca.uhn.fhirtest.config;
import ca.uhn.fhir.batch2.jobs.config.Batch2JobsConfig; import ca.uhn.fhir.batch2.jobs.config.Batch2JobsConfig;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.interceptor.api.IInterceptorService; import ca.uhn.fhir.interceptor.api.IInterceptorService;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
import ca.uhn.fhir.jpa.api.config.ThreadPoolFactoryConfig; import ca.uhn.fhir.jpa.api.config.ThreadPoolFactoryConfig;
import ca.uhn.fhir.jpa.batch2.JpaBatch2Config; import ca.uhn.fhir.jpa.batch2.JpaBatch2Config;
import ca.uhn.fhir.storage.interceptor.balp.AsyncMemoryQueueBackedFhirClientBalpSink;
import ca.uhn.fhir.storage.interceptor.balp.BalpAuditCaptureInterceptor;
import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditContextServices;
import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditEventSink;
import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings;
import ca.uhn.fhir.jpa.subscription.channel.config.SubscriptionChannelConfig; import ca.uhn.fhir.jpa.subscription.channel.config.SubscriptionChannelConfig;
import ca.uhn.fhir.jpa.subscription.match.config.SubscriptionProcessorConfig; import ca.uhn.fhir.jpa.subscription.match.config.SubscriptionProcessorConfig;
@ -97,10 +102,6 @@ public class CommonConfig {
return retVal; return retVal;
} }
public static boolean isLocalTestMode() {
return "true".equalsIgnoreCase(System.getProperty("testmode.local"));
}
@Bean @Bean
public ScheduledSubscriptionDeleter scheduledSubscriptionDeleter() { public ScheduledSubscriptionDeleter scheduledSubscriptionDeleter() {
return new ScheduledSubscriptionDeleter(); return new ScheduledSubscriptionDeleter();
@ -111,4 +112,24 @@ public class CommonConfig {
return new CommonJpaStorageSettingsConfigurer(theStorageSettings); return new CommonJpaStorageSettingsConfigurer(theStorageSettings);
} }
@Bean
public IBalpAuditEventSink balpAuditEventSink(FhirContext theFhirContext) {
return new AsyncMemoryQueueBackedFhirClientBalpSink(theFhirContext, "http://localhost:8000/baseAudit");
}
@Bean
public BalpAuditCaptureInterceptor balpAuditCaptureInterceptor(IBalpAuditEventSink theAuditSink, IBalpAuditContextServices theAuditContextServices) {
return new BalpAuditCaptureInterceptor(theAuditSink, theAuditContextServices);
}
@Bean
public IBalpAuditContextServices balpContextServices() {
return new FhirTestBalpAuditContextServices();
}
public static boolean isLocalTestMode() {
return "true".equalsIgnoreCase(System.getProperty("testmode.local"));
}
} }

View File

@ -0,0 +1,78 @@
package ca.uhn.fhirtest.config;
import ca.uhn.fhir.storage.interceptor.balp.IBalpAuditContextServices;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import joptsimple.internal.Strings;
import org.hl7.fhir.r4.model.Reference;
import javax.annotation.Nonnull;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank;
/**
* This class is an implementation of the {@link IBalpAuditContextServices}
* interface for the public HAPI FHIR
*/
public class FhirTestBalpAuditContextServices implements IBalpAuditContextServices {
/**
* Create and return a Reference to the client that was used to
* perform the action in question.
* <p>
* In this case we are simply returning the HTTP User Agent (ie the name of the
* browser or HTTP client) because this server is anonymous. In a real implementation
* it would make more sense to return the identity of the SMART on FHIR
* client or something similar.
*/
@Nonnull
@Override
public Reference getAgentClientWho(RequestDetails theRequestDetails) {
String userAgent = theRequestDetails.getHeader("User-Agent");
if (isBlank(userAgent)) {
userAgent = "Unknown User Agent";
}
Reference retVal = new Reference();
retVal.setDisplay(userAgent);
return retVal;
}
/**
* Create and return a Reference to the user that was used to
* perform the action in question.
* <p>
* In this case because this is an anoymous server we simply use
* a masked version of the user's IP as their identity. In a real
* server this should have details about the user account.
*/
@Nonnull
@Override
public Reference getAgentUserWho(RequestDetails theRequestDetails) {
Reference retVal = new Reference();
retVal.setDisplay(getNetworkAddress(theRequestDetails));
return retVal;
}
/**
* Provide the requesting network address to include in the AuditEvent
* <p>
* Because this is a public server and these audit events will be visible
* to the outside world, we mask the latter half of the requesting IP
* address in order to not leak the identity of our users.
*/
@Override
public String getNetworkAddress(RequestDetails theRequestDetails) {
ServletRequestDetails srd = (ServletRequestDetails) theRequestDetails;
String remoteAddr = defaultString(srd.getServletRequest().getRemoteAddr(), "UNKNOWN");
String[] parts = remoteAddr.split("\\.");
// Obscure part of the IP address
if (parts.length >= 4) {
parts[2] = "X";
parts[3] = "X";
}
return Strings.join(parts, ".");
}
}

View File

@ -51,6 +51,21 @@ public class FhirTesterConfig {
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType())) .withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.withSearchResultRowOperation("$summary", id -> "Patient".equals(id.getResourceType())) .withSearchResultRowOperation("$summary", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("home_r5")
.withFhirVersion(FhirVersionEnum.R5)
.withBaseUrl("http://hapi.fhir.org/baseR5")
.withName("HAPI Test Server (R5 FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("home_audit")
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("http://hapi.fhir.org/baseAudit")
.withName("HAPI Test Server (R4 Audit)")
.addServer() .addServer()
.withId("home_r4b") .withId("home_r4b")
.withFhirVersion(FhirVersionEnum.R4B) .withFhirVersion(FhirVersionEnum.R4B)
@ -77,15 +92,6 @@ public class FhirTesterConfig {
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true) .withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType())) .withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("home_r5")
.withFhirVersion(FhirVersionEnum.R5)
.withBaseUrl("http://hapi.fhir.org/baseR5")
.withName("HAPI Test Server (R5 FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
// Non-HAPI servers follow // Non-HAPI servers follow
.addServer() .addServer()

View File

@ -0,0 +1,188 @@
package ca.uhn.fhirtest.config;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
import ca.uhn.fhir.jpa.config.HapiJpaConfig;
import ca.uhn.fhir.jpa.config.r4.JpaR4Config;
import ca.uhn.fhir.jpa.config.util.HapiEntityManagerFactoryUtil;
import ca.uhn.fhir.jpa.ips.api.IIpsGenerationStrategy;
import ca.uhn.fhir.jpa.ips.generator.IIpsGeneratorSvc;
import ca.uhn.fhir.jpa.ips.generator.IpsGeneratorSvcImpl;
import ca.uhn.fhir.jpa.ips.provider.IpsOperationProvider;
import ca.uhn.fhir.jpa.ips.strategy.DefaultIpsGenerationStrategy;
import ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect;
import ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgres94Dialect;
import ca.uhn.fhir.jpa.search.DatabaseBackedPagingProvider;
import ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers;
import ca.uhn.fhir.jpa.util.CurrentThreadCaptureQueriesListener;
import ca.uhn.fhir.jpa.validation.ValidationSettings;
import ca.uhn.fhir.rest.server.interceptor.RequestValidatingInterceptor;
import ca.uhn.fhir.validation.IInstanceValidatorModule;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import ca.uhn.fhirtest.interceptor.PublicSecurityInterceptor;
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
import org.apache.commons.dbcp2.BasicDataSource;
import org.hibernate.search.backend.lucene.cfg.LuceneBackendSettings;
import org.hibernate.search.backend.lucene.cfg.LuceneIndexSettings;
import org.hibernate.search.engine.cfg.BackendSettings;
import org.hl7.fhir.dstu2.model.Subscription;
import org.hl7.fhir.r5.utils.validation.constants.ReferenceValidationPolicy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Primary;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.annotation.PostConstruct;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
@Configuration
@Import({CommonConfig.class, JpaR4Config.class, HapiJpaConfig.class})
@EnableTransactionManagement()
public class TestAuditConfig {
public static final Integer COUNT_SEARCH_RESULTS_UP_TO = 50000;
private String myDbUsername = System.getProperty(TestR5Config.FHIR_DB_USERNAME);
private String myDbPassword = System.getProperty(TestR5Config.FHIR_DB_PASSWORD);
@Bean
public JpaStorageSettings storageSettings() {
JpaStorageSettings retVal = new JpaStorageSettings();
retVal.setAllowContainsSearches(true);
retVal.setAllowMultipleDelete(true);
retVal.setAllowInlineMatchUrlReferences(false);
retVal.setAllowExternalReferences(true);
retVal.getTreatBaseUrlsAsLocal().add("http://hapi.fhir.org/baseAudit");
retVal.getTreatBaseUrlsAsLocal().add("https://hapi.fhir.org/baseAudit");
retVal.setIndexMissingFields(JpaStorageSettings.IndexEnabledEnum.DISABLED);
retVal.setCountSearchResultsUpTo(TestAuditConfig.COUNT_SEARCH_RESULTS_UP_TO);
retVal.setFetchSizeDefaultMaximum(10000);
retVal.setExpungeEnabled(true);
retVal.setAllowExternalReferences(true);
retVal.setFilterParameterEnabled(true);
retVal.setDefaultSearchParamsCanBeOverridden(false);
retVal.setIndexOnContainedResources(false);
retVal.setIndexIdentifierOfType(false);
return retVal;
}
@Bean
public ValidationSettings validationSettings() {
ValidationSettings retVal = new ValidationSettings();
retVal.setLocalReferenceValidationDefaultPolicy(ReferenceValidationPolicy.CHECK_VALID);
return retVal;
}
@Bean(name = "myPersistenceDataSourceR4")
public DataSource dataSource() {
BasicDataSource retVal = new BasicDataSource();
if (CommonConfig.isLocalTestMode()) {
retVal.setUrl("jdbc:h2:mem:fhirtest_audit");
} else {
retVal.setDriver(new org.postgresql.Driver());
retVal.setUrl("jdbc:postgresql://localhost/fhirtest_audit");
}
retVal.setUsername(myDbUsername);
retVal.setPassword(myDbPassword);
TestR5Config.applyCommonDatasourceParams(retVal);
DataSource dataSource = ProxyDataSourceBuilder
.create(retVal)
// .logQueryBySlf4j(SLF4JLogLevel.INFO, "SQL")
.logSlowQueryBySlf4j(10000, TimeUnit.MILLISECONDS)
.afterQuery(new CurrentThreadCaptureQueriesListener())
.countQuery()
.build();
return dataSource;
}
@Bean
public DatabaseBackedPagingProvider databaseBackedPagingProvider() {
DatabaseBackedPagingProvider retVal = new DatabaseBackedPagingProvider();
retVal.setDefaultPageSize(20);
retVal.setMaximumPageSize(500);
return retVal;
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(ConfigurableListableBeanFactory theConfigurableListableBeanFactory, FhirContext theFhirContext) {
LocalContainerEntityManagerFactoryBean retVal = HapiEntityManagerFactoryUtil.newEntityManagerFactory(theConfigurableListableBeanFactory, theFhirContext);
retVal.setPersistenceUnitName("PU_HapiFhirJpaAudit");
retVal.setDataSource(dataSource());
retVal.setJpaProperties(jpaProperties());
return retVal;
}
private Properties jpaProperties() {
Properties extraProperties = new Properties();
if (CommonConfig.isLocalTestMode()) {
extraProperties.put("hibernate.dialect", HapiFhirH2Dialect.class.getName());
} else {
extraProperties.put("hibernate.dialect", HapiFhirPostgres94Dialect.class.getName());
}
extraProperties.put("hibernate.format_sql", "false");
extraProperties.put("hibernate.show_sql", "false");
extraProperties.put("hibernate.hbm2ddl.auto", "update");
extraProperties.put("hibernate.jdbc.batch_size", "20");
extraProperties.put("hibernate.cache.use_query_cache", "false");
extraProperties.put("hibernate.cache.use_second_level_cache", "false");
extraProperties.put("hibernate.cache.use_structured_entries", "false");
extraProperties.put("hibernate.cache.use_minimal_puts", "false");
extraProperties.put("hibernate.search.enabled", "false");
return extraProperties;
}
@Bean
public PublicSecurityInterceptor securityInterceptor() {
return new PublicSecurityInterceptor();
}
@Bean
@Primary
public JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager retVal = new JpaTransactionManager();
retVal.setEntityManagerFactory(entityManagerFactory);
return retVal;
}
/**
* This lets the "@Value" fields reference properties from the properties file
*/
@Bean
public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
@Bean
public DaoRegistryConfigurer daoRegistryConfigurer() {
return new DaoRegistryConfigurer();
}
public static class DaoRegistryConfigurer {
@Autowired
private DaoRegistry myDaoRegistry;
@PostConstruct
public void start() {
myDaoRegistry.setSupportedResourceTypes("AuditEvent", "SearchParameter", "Subscription");
}
}
}

View File

@ -46,6 +46,16 @@
<load-on-startup>1</load-on-startup> <load-on-startup>1</load-on-startup>
</servlet> </servlet>
<servlet>
<servlet-name>fhirServletAudit</servlet-name>
<servlet-class>ca.uhn.fhirtest.TestRestfulServer</servlet-class>
<init-param>
<param-name>FhirVersion</param-name>
<param-value>Audit</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet> <servlet>
<servlet-name>fhirServletR4B</servlet-name> <servlet-name>fhirServletR4B</servlet-name>
<servlet-class>ca.uhn.fhirtest.TestRestfulServer</servlet-class> <servlet-class>ca.uhn.fhirtest.TestRestfulServer</servlet-class>
@ -98,6 +108,10 @@
<servlet-name>fhirServletR4</servlet-name> <servlet-name>fhirServletR4</servlet-name>
<url-pattern>/baseR4/*</url-pattern> <url-pattern>/baseR4/*</url-pattern>
</servlet-mapping> </servlet-mapping>
<servlet-mapping>
<servlet-name>fhirServletAudit</servlet-name>
<url-pattern>/baseAudit/*</url-pattern>
</servlet-mapping>
<servlet-mapping> <servlet-mapping>
<servlet-name>fhirServletDstu2</servlet-name> <servlet-name>fhirServletDstu2</servlet-name>
<url-pattern>/baseDstu2/*</url-pattern> <url-pattern>/baseDstu2/*</url-pattern>

View File

@ -45,6 +45,7 @@ public class UhnFhirTestApp {
System.setProperty("fhir.baseurl.r4", base.replace("Dstu2", "R4")); System.setProperty("fhir.baseurl.r4", base.replace("Dstu2", "R4"));
System.setProperty("fhir.baseurl.r4b", base.replace("Dstu2", "R4B")); System.setProperty("fhir.baseurl.r4b", base.replace("Dstu2", "R4B"));
System.setProperty("fhir.baseurl.r5", base.replace("Dstu2", "R5")); System.setProperty("fhir.baseurl.r5", base.replace("Dstu2", "R5"));
System.setProperty("fhir.baseurl.audit", base.replace("Dstu2", "Audit"));
System.setProperty("fhir.baseurl.tdl2", base.replace("baseDstu2", "testDataLibraryDstu2")); System.setProperty("fhir.baseurl.tdl2", base.replace("baseDstu2", "testDataLibraryDstu2"));
System.setProperty("fhir.baseurl.tdl3", base.replace("baseDstu2", "testDataLibraryStu3")); System.setProperty("fhir.baseurl.tdl3", base.replace("baseDstu2", "testDataLibraryStu3"));
System.setProperty("fhir.tdlpass", "aa,bb"); System.setProperty("fhir.tdlpass", "aa,bb");

View File

@ -19,8 +19,11 @@
*/ */
package ca.uhn.fhir.rest.api.server; package ca.uhn.fhir.rest.api.server;
import org.apache.commons.collections4.IteratorUtils;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import java.util.List;
/** /**
* This interface is a parameter type for the {@link ca.uhn.fhir.interceptor.api.Pointcut#STORAGE_PRESHOW_RESOURCES} * This interface is a parameter type for the {@link ca.uhn.fhir.interceptor.api.Pointcut#STORAGE_PRESHOW_RESOURCES}
* hook. * hook.
@ -55,4 +58,16 @@ public interface IPreResourceShowDetails extends Iterable<IBaseResource> {
*/ */
void markResourceAtIndexAsSubset(int theIndex); void markResourceAtIndexAsSubset(int theIndex);
/**
* Returns a {@link List} containing all resources that will be shown.
* The returned list will have the same relative ordering as if the resources
* were retrieved using {@link #getResource(int)}, but any {@literal null} entries
* will be filtered out.
* <p>
* The returned List may not be modified. Use this method only if you are not
* looking to make changes.
*
* @since 6.6.0
*/
List<IBaseResource> getAllResources();
} }

View File

@ -23,8 +23,10 @@ import com.google.common.collect.Lists;
import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -76,6 +78,16 @@ public class SimplePreResourceShowDetails implements IPreResourceShowDetails {
myResourceMarkedAsSubset[theIndex] = true; myResourceMarkedAsSubset[theIndex] = true;
} }
@Override
public List<IBaseResource> getAllResources() {
ArrayList<IBaseResource> retVal = new ArrayList<>(myResources.length);
Arrays
.stream(myResources)
.filter(Objects::nonNull)
.forEach(retVal::add);
return Collections.unmodifiableList(retVal);
}
@Override @Override
public Iterator<IBaseResource> iterator() { public Iterator<IBaseResource> iterator() {
return Arrays.asList(myResources).iterator(); return Arrays.asList(myResources).iterator();

View File

@ -674,6 +674,7 @@ class RuleImplOp extends BaseRule /* implements IAuthRule */ {
} }
@Override
@Nonnull @Nonnull
protected ToStringBuilder toStringBuilder() { protected ToStringBuilder toStringBuilder() {
ToStringBuilder builder = super.toStringBuilder(); ToStringBuilder builder = super.toStringBuilder();

View File

@ -28,6 +28,7 @@ import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum; import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum;
import ca.uhn.fhir.model.primitive.IdDt;
import ca.uhn.fhir.model.valueset.BundleEntryTransactionMethodEnum; import ca.uhn.fhir.model.valueset.BundleEntryTransactionMethodEnum;
import ca.uhn.fhir.rest.annotation.ConditionalUrlParam; import ca.uhn.fhir.rest.annotation.ConditionalUrlParam;
import ca.uhn.fhir.rest.annotation.Create; import ca.uhn.fhir.rest.annotation.Create;
@ -35,27 +36,27 @@ import ca.uhn.fhir.rest.annotation.Delete;
import ca.uhn.fhir.rest.annotation.History; import ca.uhn.fhir.rest.annotation.History;
import ca.uhn.fhir.rest.annotation.IdParam; import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.Read; import ca.uhn.fhir.rest.annotation.Read;
import ca.uhn.fhir.rest.annotation.RequiredParam;
import ca.uhn.fhir.rest.annotation.ResourceParam; import ca.uhn.fhir.rest.annotation.ResourceParam;
import ca.uhn.fhir.rest.annotation.Search; import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.annotation.Update; import ca.uhn.fhir.rest.annotation.Update;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.InterceptorInvocationTimingEnum; import ca.uhn.fhir.rest.api.InterceptorInvocationTimingEnum;
import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.server.IBundleProvider;
import ca.uhn.fhir.rest.api.server.IPreResourceAccessDetails; import ca.uhn.fhir.rest.api.server.IPreResourceAccessDetails;
import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails; import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.SimplePreResourceAccessDetails; import ca.uhn.fhir.rest.api.server.SimplePreResourceAccessDetails;
import ca.uhn.fhir.rest.api.server.SimplePreResourceShowDetails; import ca.uhn.fhir.rest.api.server.SimplePreResourceShowDetails;
import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
import ca.uhn.fhir.rest.param.TokenAndListParam;
import ca.uhn.fhir.rest.param.TokenOrListParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.server.IResourceProvider; import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.SimpleBundleProvider;
import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException;
import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.util.ValidateUtil; import ca.uhn.fhir.util.ValidateUtil;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
@ -64,16 +65,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.ArrayList; import java.util.*;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isBlank;
/** /**
@ -97,15 +94,15 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
private final Class<T> myResourceType; private final Class<T> myResourceType;
private final FhirContext myFhirContext; private final FhirContext myFhirContext;
private final String myResourceName; private final String myResourceName;
private final AtomicLong myDeleteCount = new AtomicLong(0);
private final AtomicLong myUpdateCount = new AtomicLong(0);
private final AtomicLong myCreateCount = new AtomicLong(0);
private final AtomicLong myReadCount = new AtomicLong(0);
protected Map<String, TreeMap<Long, T>> myIdToVersionToResourceMap = new LinkedHashMap<>(); protected Map<String, TreeMap<Long, T>> myIdToVersionToResourceMap = new LinkedHashMap<>();
protected Map<String, LinkedList<T>> myIdToHistory = new LinkedHashMap<>(); protected Map<String, LinkedList<T>> myIdToHistory = new LinkedHashMap<>();
protected LinkedList<T> myTypeHistory = new LinkedList<>(); protected LinkedList<T> myTypeHistory = new LinkedList<>();
protected AtomicLong mySearchCount = new AtomicLong(0); protected AtomicLong mySearchCount = new AtomicLong(0);
private long myNextId; private long myNextId;
private final AtomicLong myDeleteCount = new AtomicLong(0);
private final AtomicLong myUpdateCount = new AtomicLong(0);
private final AtomicLong myCreateCount = new AtomicLong(0);
private final AtomicLong myReadCount = new AtomicLong(0);
/** /**
* Constructor * Constructor
@ -282,11 +279,47 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
return retVal; return retVal;
} }
@Search @Search(allowUnknownParams = true)
public synchronized List<IBaseResource> searchAll(RequestDetails theRequestDetails) { public synchronized IBundleProvider searchAll(RequestDetails theRequestDetails) {
mySearchCount.incrementAndGet(); mySearchCount.incrementAndGet();
List<T> retVal = getAllResources(); List<T> allResources = getAllResources();
return fireInterceptorsAndFilterAsNeeded(retVal, theRequestDetails);
if (theRequestDetails.getParameters().containsKey(Constants.PARAM_ID)) {
for (String nextParam : theRequestDetails.getParameters().get(Constants.PARAM_ID)) {
List<IdDt> wantIds = Arrays.stream(nextParam.split(","))
.map(StringUtils::trim)
.filter(StringUtils::isNotBlank)
.map(IdDt::new)
.collect(Collectors.toList());
for (Iterator<T> iter = allResources.iterator(); iter.hasNext(); ) {
T next = iter.next();
boolean found = wantIds
.stream()
.anyMatch(t -> resourceIdMatches(next, t));
if (!found) {
iter.remove();
}
}
}
}
return new SimpleBundleProvider(allResources) {
@SuppressWarnings("unchecked")
@Nonnull
@Override
public List<IBaseResource> getResources(int theFromIndex, int theToIndex) {
// Make sure that "from" isn't less than 0, "to" isn't more than the number available,
// and "from" <= "to"
int from = max(0, theFromIndex);
int to = min(theToIndex, allResources.size());
to = max(from, to);
List<IBaseResource> retVal = (List<IBaseResource>) allResources.subList(from, to);
retVal = fireInterceptorsAndFilterAsNeeded(retVal, theRequestDetails);
return retVal;
}
};
} }
@Nonnull @Nonnull
@ -310,44 +343,6 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
return retVal; return retVal;
} }
@Search
public synchronized List<IBaseResource> searchById(
@RequiredParam(name = "_id") TokenAndListParam theIds, RequestDetails theRequestDetails) {
List<T> retVal = new ArrayList<>();
for (TreeMap<Long, T> next : myIdToVersionToResourceMap.values()) {
if (next.isEmpty() == false) {
T nextResource = next.lastEntry().getValue();
boolean matches = true;
if (theIds != null && theIds.getValuesAsQueryTokens().size() > 0) {
for (TokenOrListParam nextIdAnd : theIds.getValuesAsQueryTokens()) {
matches = false;
for (TokenParam nextOr : nextIdAnd.getValuesAsQueryTokens()) {
if (nextOr.getValue().equals(nextResource.getIdElement().getIdPart())) {
matches = true;
}
}
if (!matches) {
break;
}
}
}
if (!matches) {
continue;
}
retVal.add(nextResource);
}
}
mySearchCount.incrementAndGet();
return fireInterceptorsAndFilterAsNeeded(retVal, theRequestDetails);
}
@SuppressWarnings({"unchecked", "DataFlowIssue"}) @SuppressWarnings({"unchecked", "DataFlowIssue"})
private IIdType store(@Nonnull T theResource, String theIdPart, Long theVersionIdPart, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails, boolean theDeleted) { private IIdType store(@Nonnull T theResource, String theIdPart, Long theVersionIdPart, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails, boolean theDeleted) {
IIdType id = myFhirContext.getVersion().newIdType(); IIdType id = myFhirContext.getVersion().newIdType();
@ -386,10 +381,6 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
ourLog.info("Storing resource with ID: {}", id.getValue()); ourLog.info("Storing resource with ID: {}", id.getValue());
// Store to ID->version->resource map
TreeMap<Long, T> versionToResource = getVersionToResource(theIdPart);
versionToResource.put(theVersionIdPart, theResource);
if (theRequestDetails != null && theRequestDetails.getInterceptorBroadcaster() != null) { if (theRequestDetails != null && theRequestDetails.getInterceptorBroadcaster() != null) {
IInterceptorBroadcaster interceptorBroadcaster = theRequestDetails.getInterceptorBroadcaster(); IInterceptorBroadcaster interceptorBroadcaster = theRequestDetails.getInterceptorBroadcaster();
@ -457,6 +448,10 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
} }
} }
// Store to ID->version->resource map
TreeMap<Long, T> versionToResource = getVersionToResource(theIdPart);
versionToResource.put(theVersionIdPart, theResource);
// Store to type history map // Store to type history map
myTypeHistory.addFirst(theResource); myTypeHistory.addFirst(theResource);
@ -542,6 +537,15 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
return Collections.unmodifiableList(retVal); return Collections.unmodifiableList(retVal);
} }
private boolean resourceIdMatches(T theResource, IdDt theId) {
if (theId.getResourceType() == null || theId.getResourceType().equals(myFhirContext.getResourceType(theResource))) {
if (theResource.getIdElement().getIdPart().equals(theId.getIdPart())) {
return true;
}
}
return false;
}
private static <T extends IBaseResource> T fireInterceptorsAndFilterAsNeeded(T theResource, RequestDetails theRequestDetails) { private static <T extends IBaseResource> T fireInterceptorsAndFilterAsNeeded(T theResource, RequestDetails theRequestDetails) {
List<IBaseResource> output = fireInterceptorsAndFilterAsNeeded(Lists.newArrayList(theResource), theRequestDetails); List<IBaseResource> output = fireInterceptorsAndFilterAsNeeded(Lists.newArrayList(theResource), theRequestDetails);
if (output.size() == 1) { if (output.size() == 1) {

View File

@ -1,16 +1,15 @@
package ca.uhn.fhir.rest.api.server; package ca.uhn.fhir.rest.api.server;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
@ -51,4 +50,16 @@ public class SimplePreResourceShowDetailsTest {
details.setResource(0, myResource2); details.setResource(0, myResource2);
assertSame(myResource2, details.iterator().next()); assertSame(myResource2, details.iterator().next());
} }
@Test
public void testGetResources() {
SimplePreResourceShowDetails details = new SimplePreResourceShowDetails(List.of(myResource1, myResource2));
assertThat(details.getAllResources(), contains(myResource1, myResource2));
details.setResource(0, null);
assertThat(details.getAllResources(), contains(myResource2));
}
} }

View File

@ -0,0 +1,147 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* This implementation of the {@link IBalpAuditEventSink} transmits audit events to
* a FHIR endpoint for creation, using a standard fhir <i>create</i> event. The target
* server FHIR version does not need to match the FHIR version of the AuditEvent source,
* events will be converted automatically prior to sending.
* <p>
* This sink transmits events asynchronously using an in-memory queue. This means that
* in the event of a server shutdown data could be lost.
* </p>
*/
public class AsyncMemoryQueueBackedFhirClientBalpSink extends FhirClientBalpSink implements IBalpAuditEventSink {
private static final Logger ourLog = LoggerFactory.getLogger(AsyncMemoryQueueBackedFhirClientBalpSink.class);
private final ArrayBlockingQueue<IBaseResource> myQueue;
private static final AtomicLong ourNextThreadId = new AtomicLong(0);
private boolean myRunning;
private TransmitterThread myThread;
/**
* Sets the FhirContext to use when initiating outgoing connections
*
* @param theFhirContext The FhirContext instance. This context must be
* for the FHIR Version supported by the target/sink
* server (as opposed to the FHIR Version supported
* by the audit source).
* @param theTargetBaseUrl The FHIR server base URL for the target/sink server to
* receive audit events.
*/
public AsyncMemoryQueueBackedFhirClientBalpSink(@Nonnull FhirContext theFhirContext, @Nonnull String theTargetBaseUrl) {
this(theFhirContext, theTargetBaseUrl, null);
}
/**
* Sets the FhirContext to use when initiating outgoing connections
*
* @param theFhirContext The FhirContext instance. This context must be
* for the FHIR Version supported by the target/sink
* server (as opposed to the FHIR Version supported
* by the audit source).
* @param theTargetBaseUrl The FHIR server base URL for the target/sink server to
* receive audit events.
* @param theClientInterceptors An optional list of interceptors to register against
* the client. May be {@literal null}.
*/
public AsyncMemoryQueueBackedFhirClientBalpSink(@Nonnull FhirContext theFhirContext, @Nonnull String theTargetBaseUrl, @Nullable List<Object> theClientInterceptors) {
this(createClient(theFhirContext, theTargetBaseUrl, theClientInterceptors));
}
/**
* Constructor
*
* @param theClient The FHIR client to use as a sink.
*/
public AsyncMemoryQueueBackedFhirClientBalpSink(IGenericClient theClient) {
super(theClient);
myQueue = new ArrayBlockingQueue<>(100);
}
@Override
protected void recordAuditEvent(IBaseResource theAuditEvent) {
myQueue.add(theAuditEvent);
}
@PostConstruct
public void start() {
if (!myRunning) {
myRunning = true;
myThread = new TransmitterThread();
myThread.start();
}
}
@PreDestroy
public void stop() {
if (myRunning) {
myRunning = false;
myThread.interrupt();
}
}
public boolean isRunning() {
return myThread != null && myThread.isRunning();
}
private class TransmitterThread extends Thread {
private boolean myThreadRunning;
public TransmitterThread() {
setName("BalpClientSink-" + ourNextThreadId.getAndIncrement());
}
@Override
public void run() {
ourLog.info("Starting BALP Client Sink Transmitter");
myThreadRunning = true;
while (myRunning) {
IBaseResource next = null;
try {
next = myQueue.poll(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// TODO: Currently we transmit events one by one, but a nice optimization
// would be to batch them into FHIR transaction Bundles. If we do this, we
// would get better performance, but we'd also want to have some retry
// logic that submits events individually if a transaction fails.
if (next != null) {
try {
transmitEventToClient(next);
} catch (Exception e) {
ourLog.warn("Failed to transmit AuditEvent to sink: {}", e.toString());
myQueue.add(next);
}
}
}
ourLog.info("Stopping BALP Client Sink Transmitter");
myThreadRunning = false;
}
public boolean isRunning() {
return myThreadRunning;
}
}
}

View File

@ -0,0 +1,393 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.util.FhirTerser;
import ca.uhn.fhir.util.UrlUtil;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.AuditEvent;
import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
/**
* The IHE Basic Audit Logging Pattern (BALP) interceptor can be used to autopmatically generate
* AuditEvent resources that are conformant to the BALP profile in response to events in a
* FHIR server. See <a href="https://hapifhir.io/hapi-fhir/security/balp_interceptor.html">BALP Interceptor</a>
* in the HAPI FHIR documentation for more information.
*
* @since 6.6.0
*/
public class BalpAuditCaptureInterceptor {
private final IBalpAuditEventSink myAuditEventSink;
private final IBalpAuditContextServices myContextServices;
private Set<String> myAdditionalPatientCompartmentParamNames;
/**
* Constructor
*
* @param theAuditEventSink This service is the target for generated AuditEvent resources. The {@link BalpAuditCaptureInterceptor}
* does not actually store AuditEvents, it simply generates them when appropriate and passes them to
* the sink service. The sink service might store them locally, transmit them to a remote
* repository, or even simply log them to a syslog.
* @param theContextServices This service supplies details to the BALP about the context of a given request. For example,
* in order to generate a conformant AuditEvent resource, this interceptor needs to determine the
* identity of the user and the client from the {@link ca.uhn.fhir.rest.api.server.RequestDetails}
* object.
*/
public BalpAuditCaptureInterceptor(@Nonnull IBalpAuditEventSink theAuditEventSink, @Nonnull IBalpAuditContextServices theContextServices) {
Validate.notNull(theAuditEventSink);
Validate.notNull(theContextServices);
myAuditEventSink = theAuditEventSink;
myContextServices = theContextServices;
}
private static void addEntityPatient(AuditEvent theAuditEvent, String thePatientId) {
AuditEvent.AuditEventEntityComponent entityPatient = theAuditEvent.addEntity();
entityPatient
.getType()
.setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
.setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_1_PERSON)
.setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_1_PERSON_DISPLAY);
entityPatient
.getRole()
.setSystem(BalpConstants.CS_OBJECT_ROLE)
.setCode(BalpConstants.CS_OBJECT_ROLE_1_PATIENT)
.setDisplay(BalpConstants.CS_OBJECT_ROLE_1_PATIENT_DISPLAY);
entityPatient
.getWhat()
.setReference(thePatientId);
}
private static void addEntityData(AuditEvent theAuditEvent, String theDataResourceId) {
AuditEvent.AuditEventEntityComponent entityData = theAuditEvent.addEntity();
entityData
.getType()
.setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
.setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT)
.setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT_DISPLAY);
entityData
.getRole()
.setSystem(BalpConstants.CS_OBJECT_ROLE)
.setCode(BalpConstants.CS_OBJECT_ROLE_4_DOMAIN_RESOURCE)
.setDisplay(BalpConstants.CS_OBJECT_ROLE_4_DOMAIN_RESOURCE_DISPLAY);
entityData
.getWhat()
.setReference(theDataResourceId);
}
public void setAdditionalPatientCompartmentParamNames(Set<String> theAdditionalPatientCompartmentParamNames) {
myAdditionalPatientCompartmentParamNames = theAdditionalPatientCompartmentParamNames;
}
/**
* Interceptor hook method. Do not call directly.
*/
@SuppressWarnings("EnumSwitchStatementWhichMissesCases")
@Hook(Pointcut.STORAGE_PRESHOW_RESOURCES)
void hookStoragePreShowResources(IPreResourceShowDetails theDetails, ServletRequestDetails theRequestDetails) {
switch (theRequestDetails.getRestOperationType()) {
case SEARCH_TYPE:
case SEARCH_SYSTEM:
case GET_PAGE:
handleSearch(theDetails, theRequestDetails);
break;
case READ:
case VREAD:
handleReadOrVRead(theDetails, theRequestDetails);
break;
default:
// No actions for other operations
}
}
@Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED)
public void hookStoragePrecommitResourceCreated(IBaseResource theResource, ServletRequestDetails theRequestDetails) {
handleCreateUpdateDelete(theResource, theRequestDetails, BalpProfileEnum.BASIC_CREATE, BalpProfileEnum.PATIENT_CREATE);
}
@Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_DELETED)
public void hookStoragePrecommitResourceDeleted(IBaseResource theResource, ServletRequestDetails theRequestDetails) {
handleCreateUpdateDelete(theResource, theRequestDetails, BalpProfileEnum.BASIC_DELETE, BalpProfileEnum.PATIENT_DELETE);
}
@Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_UPDATED)
public void hookStoragePrecommitResourceUpdated(IBaseResource theOldResource, IBaseResource theResource, ServletRequestDetails theRequestDetails) {
handleCreateUpdateDelete(theResource, theRequestDetails, BalpProfileEnum.BASIC_UPDATE, BalpProfileEnum.PATIENT_UPDATE);
}
private void handleCreateUpdateDelete(IBaseResource theResource, ServletRequestDetails theRequestDetails, BalpProfileEnum theBasicProfile, BalpProfileEnum thePatientProfile) {
Set<String> patientCompartmentOwners = determinePatientCompartmentOwnersForResources(List.of(theResource), theRequestDetails);
if (patientCompartmentOwners.isEmpty()) {
AuditEvent auditEvent = createAuditEventBasicCreateUpdateDelete(theRequestDetails, theResource, theBasicProfile);
myAuditEventSink.recordAuditEvent(auditEvent);
} else {
AuditEvent auditEvent = createAuditEventPatientCreateUpdateDelete(theRequestDetails, theResource, patientCompartmentOwners, thePatientProfile);
myAuditEventSink.recordAuditEvent(auditEvent);
}
}
private void handleReadOrVRead(IPreResourceShowDetails theDetails, ServletRequestDetails theRequestDetails) {
Validate.isTrue(theDetails.size() == 1, "Unexpected number of results for read: %d", theDetails.size());
IBaseResource resource = theDetails.getResource(0);
if (resource != null) {
String dataResourceId = myContextServices.massageResourceIdForStorage(theRequestDetails, resource, resource.getIdElement());
Set<String> patientIds = determinePatientCompartmentOwnersForResources(List.of(resource), theRequestDetails);
// If the resource is in the Patient compartment, create one audit
// event for each compartment owner
for (String patientId : patientIds) {
AuditEvent auditEvent = createAuditEventPatientRead(theRequestDetails, dataResourceId, patientId);
myAuditEventSink.recordAuditEvent(auditEvent);
}
// Otherwise, this is a basic read so create a basic read audit event
if (patientIds.isEmpty()) {
AuditEvent auditEvent = createAuditEventBasicRead(theRequestDetails, dataResourceId);
myAuditEventSink.recordAuditEvent(auditEvent);
}
}
}
private void handleSearch(IPreResourceShowDetails theDetails, ServletRequestDetails theRequestDetails) {
List<IBaseResource> resources = theDetails.getAllResources();
Set<String> compartmentOwners = determinePatientCompartmentOwnersForResources(resources, theRequestDetails);
if (!compartmentOwners.isEmpty()) {
AuditEvent auditEvent = createAuditEventPatientQuery(theRequestDetails, compartmentOwners);
myAuditEventSink.recordAuditEvent(auditEvent);
} else {
AuditEvent auditEvent = createAuditEventBasicQuery(theRequestDetails);
myAuditEventSink.recordAuditEvent(auditEvent);
}
}
@Nonnull
private Set<String> determinePatientCompartmentOwnersForResources(List<IBaseResource> theResources, ServletRequestDetails theRequestDetails) {
Set<String> patientIds = new TreeSet<>();
FhirContext fhirContext = theRequestDetails.getFhirContext();
for (IBaseResource resource : theResources) {
RuntimeResourceDefinition resourceDef = fhirContext.getResourceDefinition(resource);
if (resourceDef.getName().equals("Patient")) {
patientIds.add(myContextServices.massageResourceIdForStorage(theRequestDetails, resource, resource.getIdElement()));
} else {
List<RuntimeSearchParam> compartmentSearchParameters = resourceDef.getSearchParamsForCompartmentName("Patient");
if (!compartmentSearchParameters.isEmpty()) {
FhirTerser terser = fhirContext.newTerser();
terser
.getCompartmentOwnersForResource("Patient", resource, myAdditionalPatientCompartmentParamNames)
.stream()
.map(t -> myContextServices.massageResourceIdForStorage(theRequestDetails, resource, t))
.forEach(patientIds::add);
}
}
}
return patientIds;
}
@Nonnull
private AuditEvent createAuditEventCommonCreate(ServletRequestDetails theRequestDetails, IBaseResource theResource, BalpProfileEnum profile) {
AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, profile);
String resourceId = myContextServices.massageResourceIdForStorage(theRequestDetails, theResource, theResource.getIdElement());
addEntityData(auditEvent, resourceId);
return auditEvent;
}
@Nonnull
private AuditEvent createAuditEventBasicCreateUpdateDelete(ServletRequestDetails theRequestDetails, IBaseResource theResource, BalpProfileEnum theProfile) {
return createAuditEventCommonCreate(theRequestDetails, theResource, theProfile);
}
@Nonnull
private AuditEvent createAuditEventBasicQuery(ServletRequestDetails theRequestDetails) {
BalpProfileEnum profile = BalpProfileEnum.BASIC_QUERY;
AuditEvent auditEvent = createAuditEventCommonQuery(theRequestDetails, profile);
return auditEvent;
}
@Nonnull
private AuditEvent createAuditEventBasicRead(ServletRequestDetails theRequestDetails, String dataResourceId) {
return createAuditEventCommonRead(theRequestDetails, dataResourceId, BalpProfileEnum.BASIC_READ);
}
@Nonnull
private AuditEvent createAuditEventPatientCreateUpdateDelete(ServletRequestDetails theRequestDetails, IBaseResource theResource, Set<String> thePatientCompartmentOwners, BalpProfileEnum theProfile) {
AuditEvent retVal = createAuditEventCommonCreate(theRequestDetails, theResource, theProfile);
for (String next : thePatientCompartmentOwners) {
addEntityPatient(retVal, next);
}
return retVal;
}
@Nonnull
private AuditEvent createAuditEventPatientQuery(ServletRequestDetails theRequestDetails, Set<String> compartmentOwners) {
BalpProfileEnum profile = BalpProfileEnum.PATIENT_QUERY;
AuditEvent auditEvent = createAuditEventCommonQuery(theRequestDetails, profile);
for (String next : compartmentOwners) {
addEntityPatient(auditEvent, next);
}
return auditEvent;
}
@Nonnull
private AuditEvent createAuditEventPatientRead(ServletRequestDetails theRequestDetails, String dataResourceId, String patientId) {
BalpProfileEnum profile = BalpProfileEnum.PATIENT_READ;
AuditEvent auditEvent = createAuditEventCommonRead(theRequestDetails, dataResourceId, profile);
addEntityPatient(auditEvent, patientId);
return auditEvent;
}
@Nonnull
private AuditEvent createAuditEventCommon(ServletRequestDetails theRequestDetails, BalpProfileEnum theProfile) {
RestOperationTypeEnum restOperationType = theRequestDetails.getRestOperationType();
if (restOperationType == RestOperationTypeEnum.GET_PAGE) {
restOperationType = RestOperationTypeEnum.SEARCH_TYPE;
}
AuditEvent auditEvent = new AuditEvent();
auditEvent.getMeta().addProfile(theProfile.getProfileUrl());
auditEvent.getType()
.setSystem(BalpConstants.CS_AUDIT_EVENT_TYPE)
.setCode("rest")
.setDisplay("Restful Operation");
auditEvent.addSubtype()
.setSystem(BalpConstants.CS_RESTFUL_INTERACTION)
.setCode(restOperationType.getCode())
.setDisplay(restOperationType.getCode());
auditEvent.setAction(theProfile.getAction());
auditEvent.setOutcome(AuditEvent.AuditEventOutcome._0);
auditEvent.setRecorded(new Date());
auditEvent
.getSource()
.getObserver()
.setDisplay(theRequestDetails.getServerBaseForRequest());
AuditEvent.AuditEventAgentComponent clientAgent = auditEvent.addAgent();
clientAgent.setWho(myContextServices.getAgentClientWho(theRequestDetails));
clientAgent
.getType()
.addCoding(theProfile.getAgentClientTypeCoding());
clientAgent
.getWho()
.setDisplay(myContextServices.getNetworkAddress(theRequestDetails));
clientAgent
.getNetwork()
.setAddress(myContextServices.getNetworkAddress(theRequestDetails))
.setType(myContextServices.getNetworkAddressType(theRequestDetails));
clientAgent.setRequestor(false);
AuditEvent.AuditEventAgentComponent serverAgent = auditEvent.addAgent();
serverAgent
.getType()
.addCoding(theProfile.getAgentServerTypeCoding());
serverAgent
.getWho()
.setDisplay(theRequestDetails.getServerBaseForRequest());
serverAgent
.getNetwork()
.setAddress(theRequestDetails.getServerBaseForRequest());
serverAgent.setRequestor(false);
AuditEvent.AuditEventAgentComponent userAgent = auditEvent.addAgent();
userAgent
.getType()
.addCoding()
.setSystem("http://terminology.hl7.org/CodeSystem/v3-ParticipationType")
.setCode("IRCP")
.setDisplay("information recipient");
userAgent.setWho(myContextServices.getAgentUserWho(theRequestDetails));
userAgent
.setRequestor(true);
AuditEvent.AuditEventEntityComponent entityTransaction = auditEvent.addEntity();
entityTransaction
.getType()
.setSystem("https://profiles.ihe.net/ITI/BALP/CodeSystem/BasicAuditEntityType")
.setCode("XrequestId");
entityTransaction
.getWhat()
.getIdentifier()
.setValue(theRequestDetails.getRequestId());
return auditEvent;
}
@Nonnull
private AuditEvent createAuditEventCommonQuery(ServletRequestDetails theRequestDetails, BalpProfileEnum profile) {
AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, profile);
AuditEvent.AuditEventEntityComponent queryEntity = auditEvent.addEntity();
queryEntity
.getType()
.setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
.setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT)
.setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT_DISPLAY);
queryEntity
.getRole()
.setSystem(BalpConstants.CS_OBJECT_ROLE)
.setCode(BalpConstants.CS_OBJECT_ROLE_24_QUERY)
.setDisplay(BalpConstants.CS_OBJECT_ROLE_24_QUERY_DISPLAY);
// Description
StringBuilder description = new StringBuilder();
HttpServletRequest servletRequest = theRequestDetails.getServletRequest();
description.append(servletRequest.getMethod());
description.append(" ");
description.append(servletRequest.getRequestURI());
if (isNotBlank(servletRequest.getQueryString())) {
description.append("?");
description.append(servletRequest.getQueryString());
}
queryEntity.setDescription(description.toString());
// Query String
StringBuilder queryString = new StringBuilder();
queryString.append(theRequestDetails.getServerBaseForRequest());
queryString.append("/");
queryString.append(theRequestDetails.getRequestPath());
boolean first = true;
for (Map.Entry<String, String[]> nextEntrySet : theRequestDetails.getParameters().entrySet()) {
for (String nextValue : nextEntrySet.getValue()) {
if (first) {
queryString.append("?");
first = false;
} else {
queryString.append("&");
}
queryString.append(UrlUtil.escapeUrlParam(nextEntrySet.getKey()));
queryString.append("=");
queryString.append(UrlUtil.escapeUrlParam(nextValue));
}
}
queryEntity
.getQueryElement()
.setValue(queryString.toString().getBytes(StandardCharsets.UTF_8));
return auditEvent;
}
@Nonnull
private AuditEvent createAuditEventCommonRead(ServletRequestDetails theRequestDetails, String theDataResourceId, BalpProfileEnum theProfile) {
AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, theProfile);
addEntityData(auditEvent, theDataResourceId);
return auditEvent;
}
}

View File

@ -0,0 +1,39 @@
package ca.uhn.fhir.storage.interceptor.balp;
import org.hl7.fhir.r4.model.AuditEvent;
public class BalpConstants {
/**
* Constant for {@link AuditEvent.AuditEventAgentNetworkType} representing the code
* <code>1 - Machine name</code>. This constant is used only for convenience since the
* existing Enum uses numerical codes that are not great for readability.
*/
public static final AuditEvent.AuditEventAgentNetworkType AUDIT_EVENT_AGENT_NETWORK_TYPE_MACHINE_NAME = AuditEvent.AuditEventAgentNetworkType._1;
/**
* Constant for {@link AuditEvent.AuditEventAgentNetworkType} representing the code
* <code>2 - IP Address</code>. This constant is used only for convenience since the
* existing Enum uses numerical codes that are not great for readability.
*/
public static final AuditEvent.AuditEventAgentNetworkType AUDIT_EVENT_AGENT_NETWORK_TYPE_IP_ADDRESS = AuditEvent.AuditEventAgentNetworkType._2;
/**
* Constant for {@link AuditEvent.AuditEventAgentNetworkType} representing the code
* <code>3 - URI</code>. This constant is used only for convenience since the
* existing Enum uses numerical codes that are not great for readability.
*/
public static final AuditEvent.AuditEventAgentNetworkType AUDIT_EVENT_AGENT_NETWORK_TYPE_URI = AuditEvent.AuditEventAgentNetworkType._5;
public static final String CS_AUDIT_EVENT_TYPE = "http://terminology.hl7.org/CodeSystem/audit-event-type";
public static final String CS_AUDIT_ENTITY_TYPE = "http://terminology.hl7.org/CodeSystem/audit-entity-type";
public static final String CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT = "2";
public static final String CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT_DISPLAY = "System Object";
public static final String CS_AUDIT_ENTITY_TYPE_1_PERSON = "1";
public static final String CS_AUDIT_ENTITY_TYPE_1_PERSON_DISPLAY = "Person";
public static final String CS_OBJECT_ROLE = "http://terminology.hl7.org/CodeSystem/object-role";
public static final String CS_OBJECT_ROLE_1_PATIENT = "1";
public static final String CS_OBJECT_ROLE_1_PATIENT_DISPLAY = "Patient";
public static final String CS_OBJECT_ROLE_4_DOMAIN_RESOURCE = "4";
public static final String CS_OBJECT_ROLE_4_DOMAIN_RESOURCE_DISPLAY = "Domain Resource";
public static final String CS_RESTFUL_INTERACTION = "http://hl7.org/fhir/restful-interaction";
public static final String CS_OBJECT_ROLE_24_QUERY = "24";
static final String CS_OBJECT_ROLE_24_QUERY_DISPLAY = "Query";
}

View File

@ -0,0 +1,102 @@
package ca.uhn.fhir.storage.interceptor.balp;
import org.hl7.fhir.r4.model.AuditEvent;
import org.hl7.fhir.r4.model.Coding;
import java.util.function.Supplier;
public enum BalpProfileEnum {
BASIC_CREATE(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Create",
AuditEvent.AuditEventAction.C,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
PATIENT_CREATE(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientCreate",
AuditEvent.AuditEventAction.C,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
BASIC_UPDATE(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Update",
AuditEvent.AuditEventAction.U,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
PATIENT_UPDATE(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientUpdate",
AuditEvent.AuditEventAction.U,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
BASIC_DELETE(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Delete",
AuditEvent.AuditEventAction.D,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110150", "Application"),
() -> new Coding("http://terminology.hl7.org/CodeSystem/provenance-participant-type", "custodian", "Custodian")
),
PATIENT_DELETE(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientDelete",
AuditEvent.AuditEventAction.D,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110150", "Application"),
() -> new Coding("http://terminology.hl7.org/CodeSystem/provenance-participant-type", "custodian", "Custodian")
),
BASIC_READ(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Read",
AuditEvent.AuditEventAction.R,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
PATIENT_READ(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientRead",
AuditEvent.AuditEventAction.R,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
BASIC_QUERY(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Query",
AuditEvent.AuditEventAction.E,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
PATIENT_QUERY(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientQuery",
AuditEvent.AuditEventAction.E,
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110153", "Source Role ID"),
() -> new Coding("http://dicom.nema.org/resources/ontology/DCM", "110152", "Destination Role ID")
),
;
private final String myProfileUrl;
private final AuditEvent.AuditEventAction myAction;
private final Supplier<Coding> myAgentClientTypeCoding;
private final Supplier<Coding> myAgentServerTypeCoding;
BalpProfileEnum(String theProfileUrl, AuditEvent.AuditEventAction theAction, Supplier<Coding> theAgentClientTypeCoding, Supplier<Coding> theAgentServerTypeCoding) {
myProfileUrl = theProfileUrl;
myAction = theAction;
myAgentClientTypeCoding = theAgentClientTypeCoding;
myAgentServerTypeCoding = theAgentServerTypeCoding;
}
public Coding getAgentClientTypeCoding() {
return myAgentClientTypeCoding.get();
}
public Coding getAgentServerTypeCoding() {
return myAgentServerTypeCoding.get();
}
public String getProfileUrl() {
return myProfileUrl;
}
public AuditEvent.AuditEventAction getAction() {
return myAction;
}
}

View File

@ -0,0 +1,85 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.hapi.converters.canonical.VersionCanonicalizer;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.AuditEvent;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.List;
public class FhirClientBalpSink implements IBalpAuditEventSink {
private final IGenericClient myClient;
private final VersionCanonicalizer myVersionCanonicalizer;
/**
* Sets the FhirContext to use when initiating outgoing connections
*
* @param theFhirContext The FhirContext instance. This context must be
* for the FHIR Version supported by the target/sink
* server (as opposed to the FHIR Version supported
* by the audit source).
* @param theTargetBaseUrl The FHIR server base URL for the target/sink server to
* receive audit events.
*/
public FhirClientBalpSink(@Nonnull FhirContext theFhirContext, @Nonnull String theTargetBaseUrl) {
this(theFhirContext, theTargetBaseUrl, null);
}
/**
* Sets the FhirContext to use when initiating outgoing connections
*
* @param theFhirContext The FhirContext instance. This context must be
* for the FHIR Version supported by the target/sink
* server (as opposed to the FHIR Version supported
* by the audit source).
* @param theTargetBaseUrl The FHIR server base URL for the target/sink server to
* receive audit events.
* @param theClientInterceptors An optional list of interceptors to register against
* the client. May be {@literal null}.
*/
public FhirClientBalpSink(@Nonnull FhirContext theFhirContext, @Nonnull String theTargetBaseUrl, @Nullable List<Object> theClientInterceptors) {
this(createClient(theFhirContext, theTargetBaseUrl, theClientInterceptors));
}
/**
* Constructor
*
* @param theClient The FHIR client to use as a sink.
*/
public FhirClientBalpSink(IGenericClient theClient) {
myClient = theClient;
myVersionCanonicalizer = new VersionCanonicalizer(myClient.getFhirContext());
}
@Override
public void recordAuditEvent(AuditEvent theAuditEvent) {
IBaseResource auditEvent = myVersionCanonicalizer.auditEventFromCanonical(theAuditEvent);
recordAuditEvent(auditEvent);
}
protected void recordAuditEvent(IBaseResource auditEvent) {
transmitEventToClient(auditEvent);
}
protected void transmitEventToClient(IBaseResource auditEvent) {
myClient
.create()
.resource(auditEvent)
.execute();
}
static IGenericClient createClient(@Nonnull FhirContext theFhirContext, @Nonnull String theTargetBaseUrl, @Nullable List<Object> theClientInterceptors) {
Validate.notNull(theFhirContext, "theFhirContext must not be null");
Validate.notBlank(theTargetBaseUrl, "theTargetBaseUrl must not be null or blank");
IGenericClient client = theFhirContext.newRestfulGenericClient(theTargetBaseUrl);
if (theClientInterceptors != null) {
theClientInterceptors.forEach(client::registerInterceptor);
}
return client;
}
}

View File

@ -0,0 +1,78 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.AuditEvent;
import org.hl7.fhir.r4.model.Reference;
import javax.annotation.Nonnull;
/**
* This interface is intended to be implemented in order to supply implementation
* strategy details to the {@link BalpAuditCaptureInterceptor}.
*
* @since 6.6.0
*/
public interface IBalpAuditContextServices {
/**
* Create and return a Reference to the client that was used to
* perform the action in question.
*
* @param theRequestDetails The request details object
*/
@Nonnull
Reference getAgentClientWho(RequestDetails theRequestDetails);
/**
* Create and return a Reference to the user that was used to
* perform the action in question.
*
* @param theRequestDetails The request details object
*/
@Nonnull
Reference getAgentUserWho(RequestDetails theRequestDetails);
/**
* Provide the requesting network address to include in the AuditEvent.
*
* @see #getNetworkAddressType(RequestDetails) If this method is returning an adress type that is not
* an IP address, you must also oerride this method and return the correct code.
*/
default String getNetworkAddress(RequestDetails theRequestDetails) {
String remoteAddr = null;
if (theRequestDetails instanceof ServletRequestDetails) {
remoteAddr = ((ServletRequestDetails) theRequestDetails).getServletRequest().getRemoteAddr();
}
return remoteAddr;
}
/**
* Provides a code representing the appropriate return type for {@link #getNetworkAddress(RequestDetails)}. The
* default implementation returns {@link BalpConstants#AUDIT_EVENT_AGENT_NETWORK_TYPE_IP_ADDRESS}.
*
* @param theRequestDetails The request details object
* @see #getNetworkAddress(RequestDetails)
* @see BalpConstants#AUDIT_EVENT_AGENT_NETWORK_TYPE_MACHINE_NAME Potential return type for this method
* @see BalpConstants#AUDIT_EVENT_AGENT_NETWORK_TYPE_IP_ADDRESS Potential return type for this method
* @see BalpConstants#AUDIT_EVENT_AGENT_NETWORK_TYPE_URI Potential return type for this method
*/
default AuditEvent.AuditEventAgentNetworkType getNetworkAddressType(RequestDetails theRequestDetails) {
return BalpConstants.AUDIT_EVENT_AGENT_NETWORK_TYPE_IP_ADDRESS;
}
/**
* Turns an entity resource ID from an {@link IIdType} to a String.
* The default implementation injects the server's base URL into
* the ID in order to create fully qualified URLs for resource
* references within BALP events.
*/
@Nonnull
default String massageResourceIdForStorage(@Nonnull RequestDetails theRequestDetails, @Nonnull IBaseResource theResource, @Nonnull IIdType theResourceId) {
String serverBaseUrl = theRequestDetails.getServerBaseForRequest();
String resourceName = theResourceId.getResourceType();
return theResourceId.withServerBase(serverBaseUrl, resourceName).getValue();
}
}

View File

@ -0,0 +1,9 @@
package ca.uhn.fhir.storage.interceptor.balp;
import org.hl7.fhir.r4.model.AuditEvent;
public interface IBalpAuditEventSink {
void recordAuditEvent(AuditEvent theAuditEvent);
}

View File

@ -0,0 +1,106 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.interceptor.api.HookParams;
import ca.uhn.fhir.interceptor.api.IAnonymousInterceptor;
import ca.uhn.fhir.interceptor.api.IPointcut;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.storage.interceptor.balp.AsyncMemoryQueueBackedFhirClientBalpSink;
import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderExtension;
import ca.uhn.fhir.test.utilities.server.RestfulServerExtension;
import org.hl7.fhir.dstu3.model.AuditEvent;
import org.hl7.fhir.r4.model.Reference;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.lessThan;
public class AsyncMemoryQueueBackedFhirClientBalpSinkTest {
@RegisterExtension
@Order(0)
private RestfulServerExtension myServer = new RestfulServerExtension(FhirVersionEnum.DSTU3);
@RegisterExtension
@Order(1)
private HashMapResourceProviderExtension<AuditEvent> myAuditEventProvider = new HashMapResourceProviderExtension<>(myServer, AuditEvent.class);
@Test
public void recordAuditEvent() {
// Setup
AsyncMemoryQueueBackedFhirClientBalpSink sink = new AsyncMemoryQueueBackedFhirClientBalpSink(myServer.getFhirContext(), myServer.getBaseUrl());
sink.start();
try {
org.hl7.fhir.r4.model.AuditEvent auditEvent1 = new org.hl7.fhir.r4.model.AuditEvent();
auditEvent1.addEntity().setWhat(new Reference("Patient/123"));
org.hl7.fhir.r4.model.AuditEvent auditEvent2 = new org.hl7.fhir.r4.model.AuditEvent();
auditEvent2.addEntity().setWhat(new Reference("Patient/456"));
// Test
sink.recordAuditEvent(auditEvent1);
sink.recordAuditEvent(auditEvent2);
// Validate
myAuditEventProvider.waitForCreateCount(2);
List<String> whats = myAuditEventProvider
.getStoredResources()
.stream()
.map(t -> t.getEntity().get(0).getReference().getReference())
.toList();
assertThat(whats, containsInAnyOrder("Patient/123", "Patient/456"));
} finally {
sink.stop();
await().until(sink::isRunning, equalTo(false));
}
}
@Test
public void recordAuditEvent_AutoRetry() {
// Setup
AsyncMemoryQueueBackedFhirClientBalpSink sink = new AsyncMemoryQueueBackedFhirClientBalpSink(myServer.getFhirContext(), myServer.getBaseUrl());
sink.start();
try {
org.hl7.fhir.r4.model.AuditEvent auditEvent1 = new org.hl7.fhir.r4.model.AuditEvent();
auditEvent1.addEntity().setWhat(new Reference("Patient/123"));
org.hl7.fhir.r4.model.AuditEvent auditEvent2 = new org.hl7.fhir.r4.model.AuditEvent();
auditEvent2.addEntity().setWhat(new Reference("Patient/456"));
AtomicInteger counter = new AtomicInteger(10);
myServer.registerAnonymousInterceptor(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED, new IAnonymousInterceptor() {
@Override
public void invoke(IPointcut thePointcut, HookParams theArgs) {
if (counter.decrementAndGet() > 0) {
throw new InternalErrorException("Intentional error for unit test");
}
}
});
// Test
sink.recordAuditEvent(auditEvent1);
sink.recordAuditEvent(auditEvent2);
// Validate
myAuditEventProvider.waitForCreateCount(2);
assertThat(counter.get(), lessThan(1));
List<String> whats = myAuditEventProvider
.getStoredResources()
.stream()
.map(t -> t.getEntity().get(0).getReference().getReference())
.toList();
assertThat(whats.toString(), whats, containsInAnyOrder("Patient/123", "Patient/456"));
} finally {
sink.stop();
await().until(sink::isRunning, equalTo(false));
}
}
}

View File

@ -0,0 +1,858 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.support.DefaultProfileValidationSupport;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.SearchStyleEnum;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
import ca.uhn.fhir.rest.server.FifoMemoryPagingProvider;
import ca.uhn.fhir.test.utilities.ITestDataBuilder;
import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderExtension;
import ca.uhn.fhir.test.utilities.server.RestfulServerExtension;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import ca.uhn.fhir.validation.SingleValidationMessage;
import ca.uhn.fhir.validation.ValidationResult;
import org.hl7.fhir.common.hapi.validation.support.*;
import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.*;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static ca.uhn.fhir.storage.interceptor.balp.BalpConstants.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@SuppressWarnings("unchecked")
@ExtendWith(MockitoExtension.class)
public class BalpAuditCaptureInterceptorTest implements ITestDataBuilder {
private static final FhirContext ourCtx = FhirContext.forR4Cached();
@RegisterExtension
@Order(0)
public static final RestfulServerExtension ourServer = new RestfulServerExtension(ourCtx)
.withPagingProvider(new FifoMemoryPagingProvider(10))
.keepAliveBetweenTests();
private static FhirValidator ourValidator;
@RegisterExtension
@Order(1)
public final HashMapResourceProviderExtension<Patient> myPatientProvider = new HashMapResourceProviderExtension<>(ourServer, Patient.class);
@RegisterExtension
@Order(1)
public final HashMapResourceProviderExtension<Observation> myObservationProvider = new HashMapResourceProviderExtension<>(ourServer, Observation.class);
@RegisterExtension
@Order(1)
public final HashMapResourceProviderExtension<CodeSystem> myCodeSystemProvider = new HashMapResourceProviderExtension<>(ourServer, CodeSystem.class);
@RegisterExtension
@Order(1)
public final HashMapResourceProviderExtension<ListResource> myListProvider = new HashMapResourceProviderExtension<>(ourServer, ListResource.class);
@Mock
private IBalpAuditEventSink myAuditEventSink;
@Mock(strictness = Mock.Strictness.LENIENT)
private IBalpAuditContextServices myContextServices;
@Captor
private ArgumentCaptor<AuditEvent> myAuditEventCaptor;
private BalpAuditCaptureInterceptor mySvc;
private IGenericClient myClient;
@Nonnull
private static List<String> getQueries(AuditEvent theAuditEvent) {
return theAuditEvent
.getEntity()
.stream()
.filter(t -> t.getType().getSystem().equals(CS_AUDIT_ENTITY_TYPE))
.filter(t -> t.getType().getCode().equals(CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT))
.filter(t -> t.getRole().getSystem().equals(CS_OBJECT_ROLE))
.filter(t -> t.getRole().getCode().equals(CS_OBJECT_ROLE_24_QUERY))
.map(t -> new String(t.getQuery(), StandardCharsets.UTF_8))
.toList();
}
@Nonnull
private static List<String> getDescriptions(AuditEvent theAuditEvent) {
return theAuditEvent
.getEntity()
.stream()
.filter(t -> t.getType().getSystem().equals(CS_AUDIT_ENTITY_TYPE))
.filter(t -> t.getType().getCode().equals(CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT))
.filter(t -> t.getRole().getSystem().equals(CS_OBJECT_ROLE))
.filter(t -> t.getRole().getCode().equals(CS_OBJECT_ROLE_24_QUERY))
.map(AuditEvent.AuditEventEntityComponent::getDescription)
.toList();
}
private static void assertType(AuditEvent theAuditEvent) {
assertEquals(CS_AUDIT_EVENT_TYPE, theAuditEvent.getType().getSystem());
assertEquals("rest", theAuditEvent.getType().getCode());
}
private static void assertSubType(AuditEvent theAuditEvent, String theSubType) {
assertEquals(CS_RESTFUL_INTERACTION, theAuditEvent.getSubtypeFirstRep().getSystem());
assertEquals(theSubType, theAuditEvent.getSubtypeFirstRep().getCode());
assertEquals(1, theAuditEvent.getSubtype().size());
}
private static void assertAuditEventValidatesAgainstBalpProfile(AuditEvent auditEvent) {
ValidationResult outcome = ourValidator.validateWithResult(auditEvent);
ourLog.info("Validation outcome: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(outcome.toOperationOutcome()));
List<SingleValidationMessage> issues = outcome
.getMessages()
.stream()
.filter(t -> t.getSeverity().ordinal() >= ResultSeverityEnum.WARNING.ordinal())
.toList();
if (!issues.isEmpty()) {
fail("Issues:\n * " + issues.stream().map(SingleValidationMessage::toString).collect(Collectors.joining("\n * ")));
}
}
private static void assertHasSystemObjectEntities(AuditEvent theAuditEvent, String... theResourceIds) {
List<String> systemObjects = theAuditEvent
.getEntity()
.stream()
.filter(t -> t.getType().getSystem().equals(CS_AUDIT_ENTITY_TYPE))
.filter(t -> t.getType().getCode().equals(CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT))
.filter(t -> t.getRole().getSystem().equals(CS_OBJECT_ROLE))
.filter(t -> t.getRole().getCode().equals(CS_OBJECT_ROLE_4_DOMAIN_RESOURCE))
.map(t -> t.getWhat().getReference())
.toList();
assertThat(Arrays.asList(theResourceIds).toString(), systemObjects, containsInAnyOrder(theResourceIds));
}
private static void assertHasPatientEntities(AuditEvent theAuditEvent, String... theResourceIds) {
List<String> patients = theAuditEvent
.getEntity()
.stream()
.filter(t -> t.getType().getSystem().equals(CS_AUDIT_ENTITY_TYPE))
.filter(t -> t.getType().getCode().equals(CS_AUDIT_ENTITY_TYPE_1_PERSON))
.filter(t -> t.getRole().getSystem().equals(CS_OBJECT_ROLE))
.filter(t -> t.getRole().getCode().equals(CS_OBJECT_ROLE_1_PATIENT))
.map(t -> t.getWhat().getReference())
.map(t -> new IdType(t).toUnqualified().getValue())
.toList();
assertThat(patients.toString(), patients, containsInAnyOrder(theResourceIds));
}
@BeforeAll
public static void beforeAll() throws IOException {
NpmPackageValidationSupport npmPackageSupport = new NpmPackageValidationSupport(ourCtx);
npmPackageSupport.loadPackageFromClasspath("classpath:balp/balp-1.1.1.tgz");
ValidationSupportChain validationSupportChain = new ValidationSupportChain(
npmPackageSupport,
new DefaultProfileValidationSupport(ourCtx),
new CommonCodeSystemsTerminologyService(ourCtx),
new InMemoryTerminologyServerValidationSupport(ourCtx),
new SnapshotGeneratingValidationSupport(ourCtx)
);
CachingValidationSupport validationSupport = new CachingValidationSupport(validationSupportChain);
ourValidator = ourCtx.newValidator();
FhirInstanceValidator validator = new FhirInstanceValidator(validationSupport);
validator.setNoExtensibleWarnings(true);
ourValidator.registerValidatorModule(validator);
}
@BeforeEach
public void before() {
ourServer.getInterceptorService().unregisterInterceptorsIf(t -> t instanceof BalpAuditCaptureInterceptor);
myClient = ourServer.getFhirClient();
myClient.capabilities().ofType(CapabilityStatement.class).execute(); // pre-validate this
myClient.registerInterceptor(new LoggingInterceptor(false));
when(myContextServices.getAgentClientWho(any())).thenReturn(new Reference().setIdentifier(new Identifier().setSystem("http://clients").setValue("123")));
when(myContextServices.getAgentUserWho(any())).thenReturn(new Reference().setIdentifier(new Identifier().setSystem("http://users").setValue("abc")));
when(myContextServices.massageResourceIdForStorage(any(), any(), any())).thenCallRealMethod();
when(myContextServices.getNetworkAddressType(any())).thenCallRealMethod();
mySvc = new BalpAuditCaptureInterceptor(myAuditEventSink, myContextServices);
ourServer.registerInterceptor(mySvc);
}
@Test
public void testCreateCodeSystem() {
// Test
CodeSystem cs = new CodeSystem();
cs.setUrl("http://foo");
MethodOutcome outcome = myClient
.create()
.resource(cs)
.execute();
IIdType csId = outcome.getId();
// Verify
assertTrue(outcome.getCreated());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.BASIC_CREATE);
assertType(auditEvent);
assertSubType(auditEvent, "create");
assertEquals(AuditEvent.AuditEventAction.C, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, csId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent);
}
@Test
public void testCreateObservation() {
// Test
Observation obs = buildResource("Observation", withSubject("Patient/P1"));
MethodOutcome outcome = myClient
.create()
.resource(obs)
.execute();
IIdType obsId = outcome.getId();
// Verify
assertTrue(outcome.getCreated());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_CREATE);
assertType(auditEvent);
assertSubType(auditEvent, "create");
assertEquals(AuditEvent.AuditEventAction.C, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, obsId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
}
@Test
public void testCreatePatient() {
// Test
Patient p = buildResource("Patient", withId("P1"), withFamily("Simpson"), withGiven("Homer"));
MethodOutcome outcome = myClient
.create()
.resource(p)
.execute();
IIdType patientId = outcome.getId();
// Verify
assertTrue(outcome.getCreated());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_CREATE);
assertType(auditEvent);
assertSubType(auditEvent, "create");
assertEquals(AuditEvent.AuditEventAction.C, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, patientId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, patientId.toUnqualified().getValue());
}
@Test
public void testDeleteCodeSystem() {
// Setup
CodeSystem cs = new CodeSystem();
cs.setUrl("http://foo");
myCodeSystemProvider.store(cs);
// Test
cs.setUrl("http://foo2");
MethodOutcome outcome = myClient
.delete()
.resource(cs)
.execute();
IIdType csId = outcome.getId();
// Verify
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.BASIC_DELETE);
assertType(auditEvent);
assertSubType(auditEvent, "delete");
assertEquals(AuditEvent.AuditEventAction.D, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, csId.withVersion("1").getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent);
}
@Test
public void testDeleteObservation() {
// Setup
Observation obs = buildResource("Observation", withSubject("Patient/P1"));
myObservationProvider.store(obs);
// Test
obs.setStatus(Observation.ObservationStatus.FINAL);
MethodOutcome outcome = myClient
.delete()
.resource(obs)
.execute();
IIdType obsId = outcome.getId();
// Verify
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_DELETE);
assertType(auditEvent);
assertSubType(auditEvent, "delete");
assertEquals(AuditEvent.AuditEventAction.D, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, obsId.withVersion("1").getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
}
@Test
public void testDeletePatient() {
// Setup
Patient p = buildResource("Patient", withId("P1"), withFamily("Simpson"), withGiven("Homer"));
myPatientProvider.store(p);
// Test
p.setActive(false);
MethodOutcome outcome = myClient
.delete()
.resource(p)
.execute();
IIdType patientId = outcome.getId();
// Verify
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_DELETE);
assertType(auditEvent);
assertSubType(auditEvent, "delete");
assertEquals(AuditEvent.AuditEventAction.D, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, patientId.withVersion("1").getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, patientId.toUnqualified().withVersion("1").getValue());
}
@Test
public void testReadPatient() {
// Setup
createPatient(withId("P1"), withFamily("Simpson"), withGiven("Homer"));
// Test
Patient patient = myClient
.read()
.resource(Patient.class)
.withId("P1")
.execute();
// Verify
assertEquals("Simpson", patient.getNameFirstRep().getFamily());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_READ);
assertType(auditEvent);
assertSubType(auditEvent, "read");
assertEquals(AuditEvent.AuditEventAction.R, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, patient.getId());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, patient.getIdElement().toUnqualified().getValue());
}
@Test
public void testReadResourceNotInPatientCompartment() {
// Setup
CodeSystem cs = new CodeSystem();
cs.setUrl("http://foo");
IIdType csId = myCodeSystemProvider.store(cs);
// Test
CodeSystem actual = myClient
.read()
.resource(CodeSystem.class)
.withId(csId.toUnqualifiedVersionless())
.execute();
// Verify
assertEquals("http://foo", actual.getUrl());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.BASIC_READ);
assertType(auditEvent);
assertSubType(auditEvent, "read");
assertEquals(AuditEvent.AuditEventAction.R, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, ourServer.getBaseUrl() + "/" + csId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent);
}
@Test
public void testReadResourceInPatientCompartment_WithOneSubject() {
// Setup
createObservation(withId("O1"), withSubject("Patient/P1"));
// Test
Observation observation = myClient
.read()
.resource(Observation.class)
.withId("O1")
.execute();
// Verify
assertEquals("Patient/P1", observation.getSubject().getReference());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertType(auditEvent);
assertSubType(auditEvent, "read");
assertEquals(AuditEvent.AuditEventAction.R, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, observation.getId());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
}
@Test
public void testReadResourceInPatientCompartment_WithTwoSubjects_VRead() {
// Setup
ListResource list = new ListResource();
list.addEntry().getItem().setReference("Patient/P1");
list.addEntry().getItem().setReference("Patient/P2");
IIdType listId = myListProvider.store(list);
mySvc.setAdditionalPatientCompartmentParamNames(Set.of("item"));
// Test
ListResource outcome = myClient
.read()
.resource(ListResource.class)
.withId(listId)
.execute();
// Verify
assertEquals(2, outcome.getEntry().size());
verify(myAuditEventSink, times(2)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getAllValues().get(0);
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertType(auditEvent);
assertSubType(auditEvent, "vread");
assertEquals(AuditEvent.AuditEventAction.R, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, ourServer.getBaseUrl() + "/" + listId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
}
@Test
public void testSearch_ResponseIncludesSinglePatientCompartment() {
// Setup
create10Observations("Patient/P1");
// Test
Bundle outcome = myClient
.search()
.forResource(Observation.class)
.where(Observation.SUBJECT.hasId("Patient/P1"))
.returnBundle(Bundle.class)
.execute();
// Verify
assertEquals(10, outcome.getEntry().size());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_QUERY);
assertType(auditEvent);
assertSubType(auditEvent, "search-type");
assertEquals(AuditEvent.AuditEventAction.E, auditEvent.getAction());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
assertQuery(auditEvent, ourServer.getBaseUrl() + "/Observation?subject=Patient%2FP1");
assertQueryDescription(auditEvent, "GET /Observation?subject=Patient%2FP1");
}
@Test
public void testSearch_NoPatientCompartmentResources() {
// Setup
for (int i = 0; i < 5; i++) {
CodeSystem cs = new CodeSystem();
cs.setUrl("http://cs" + i);
myCodeSystemProvider.store(cs);
}
// Test
Bundle outcome = myClient
.search()
.forResource(CodeSystem.class)
.returnBundle(Bundle.class)
.execute();
// Verify
assertEquals(5, outcome.getEntry().size());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.BASIC_QUERY);
assertType(auditEvent);
assertSubType(auditEvent, "search-type");
assertEquals(AuditEvent.AuditEventAction.E, auditEvent.getAction());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent);
assertQuery(auditEvent, ourServer.getBaseUrl() + "/CodeSystem");
assertQueryDescription(auditEvent, "GET /CodeSystem");
}
@Test
public void testSearch_ResponseIncludesSinglePatientCompartment_LoadPageTwo() {
// Setup
create10Observations("Patient/P1");
Bundle outcome = myClient
.search()
.forResource(Observation.class)
.where(Observation.SUBJECT.hasId("Patient/P1"))
.count(5)
.returnBundle(Bundle.class)
.execute();
// Test
outcome = myClient
.loadPage()
.next(outcome)
.execute();
// Verify
assertEquals(5, outcome.getEntry().size());
verify(myAuditEventSink, times(2)).recordAuditEvent(myAuditEventCaptor.capture());
verifyNoMoreInteractions(myAuditEventSink);
AuditEvent auditEvent = myAuditEventCaptor.getAllValues().get(0);
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_QUERY);
assertType(auditEvent);
assertSubType(auditEvent, "search-type");
assertEquals(AuditEvent.AuditEventAction.E, auditEvent.getAction());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
assertQuery(auditEvent, ourServer.getBaseUrl() + "/Observation?_count=5&subject=Patient%2FP1");
assertQueryDescription(auditEvent, "GET /Observation?subject=Patient%2FP1&_count=5");
auditEvent = myAuditEventCaptor.getAllValues().get(1);
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_QUERY);
assertType(auditEvent);
assertSubType(auditEvent, "search-type");
assertEquals(AuditEvent.AuditEventAction.E, auditEvent.getAction());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
}
@Test
public void testSearch_ResponseIncludesSinglePatientCompartment_UsePost() {
// Setup
create10Observations("Patient/P1");
// Test
Bundle outcome = myClient
.search()
.forResource(Observation.class)
.where(Observation.SUBJECT.hasId("Patient/P1"))
.returnBundle(Bundle.class)
.usingStyle(SearchStyleEnum.POST)
.execute();
// Verify
assertEquals(10, outcome.getEntry().size());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_QUERY);
assertType(auditEvent);
assertSubType(auditEvent, "search-type");
assertEquals(AuditEvent.AuditEventAction.E, auditEvent.getAction());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
assertQuery(auditEvent, ourServer.getBaseUrl() + "/Observation/_search?subject=Patient%2FP1");
assertQueryDescription(auditEvent, "POST /Observation/_search");
}
@Test
public void testSearch_ResponseIncludesSinglePatientCompartment_UseGetSearch() {
// Setup
create10Observations("Patient/P1");
// Test
Bundle outcome = myClient
.search()
.forResource(Observation.class)
.where(Observation.SUBJECT.hasId("Patient/P1"))
.returnBundle(Bundle.class)
.usingStyle(SearchStyleEnum.GET_WITH_SEARCH)
.execute();
// Verify
assertEquals(10, outcome.getEntry().size());
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_QUERY);
assertType(auditEvent);
assertSubType(auditEvent, "search-type");
assertEquals(AuditEvent.AuditEventAction.E, auditEvent.getAction());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
assertQuery(auditEvent, ourServer.getBaseUrl() + "/Observation/_search?subject=Patient%2FP1");
assertQueryDescription(auditEvent, "GET /Observation/_search?subject=Patient%2FP1");
}
@Test
public void testUpdateCodeSystem() {
// Setup
CodeSystem cs = new CodeSystem();
cs.setUrl("http://foo");
myCodeSystemProvider.store(cs);
// Test
cs.setUrl("http://foo2");
MethodOutcome outcome = myClient
.update()
.resource(cs)
.execute();
IIdType csId = outcome.getId();
// Verify
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.BASIC_UPDATE);
assertType(auditEvent);
assertSubType(auditEvent, "update");
assertEquals(AuditEvent.AuditEventAction.U, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, csId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent);
}
@Test
public void testUpdateObservation() {
// Setup
Observation obs = buildResource("Observation", withSubject("Patient/P1"));
myObservationProvider.store(obs);
// Test
obs.setStatus(Observation.ObservationStatus.FINAL);
MethodOutcome outcome = myClient
.update()
.resource(obs)
.execute();
IIdType obsId = outcome.getId();
// Verify
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_UPDATE);
assertType(auditEvent);
assertSubType(auditEvent, "update");
assertEquals(AuditEvent.AuditEventAction.U, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, obsId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, "Patient/P1");
}
@Test
public void testUpdatePatient() {
// Setup
Patient p = buildResource("Patient", withId("P1"), withFamily("Simpson"), withGiven("Homer"));
myPatientProvider.store(p);
// Test
p.setActive(false);
MethodOutcome outcome = myClient
.update()
.resource(p)
.execute();
IIdType patientId = outcome.getId();
// Verify
verify(myAuditEventSink, times(1)).recordAuditEvent(myAuditEventCaptor.capture());
AuditEvent auditEvent = myAuditEventCaptor.getValue();
ourLog.info("Audit Event: {}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(auditEvent));
assertAuditEventValidatesAgainstBalpProfile(auditEvent);
assertHasProfile(auditEvent, BalpProfileEnum.PATIENT_UPDATE);
assertType(auditEvent);
assertSubType(auditEvent, "update");
assertEquals(AuditEvent.AuditEventAction.U, auditEvent.getAction());
assertHasSystemObjectEntities(auditEvent, patientId.getValue());
assertEquals(AuditEvent.AuditEventOutcome._0, auditEvent.getOutcome());
assertHasPatientEntities(auditEvent, patientId.toUnqualified().getValue());
}
private void create10Observations(String... thePatientIds) {
for (int i = 0; i < 10; i++) {
createObservation(withId("O" + i), withSubject(thePatientIds[i % thePatientIds.length]));
}
}
private void assertQuery(AuditEvent theAuditEvent, String theQuery) {
List<String> queries = getQueries(theAuditEvent);
assertThat(queries, contains(theQuery));
}
private void assertQueryDescription(AuditEvent theAuditEvent, String theQuery) {
List<String> queries = getDescriptions(theAuditEvent);
assertThat(queries, contains(theQuery));
}
private void assertHasProfile(AuditEvent theAuditEvent, BalpProfileEnum theProfile) {
List<String> profiles = theAuditEvent
.getMeta()
.getProfile()
.stream()
.map(PrimitiveType::asStringValue)
.toList();
assertThat(profiles, contains(theProfile.getProfileUrl()));
}
@Override
public IIdType doCreateResource(IBaseResource theResource) {
return getProvider(theResource).create(theResource, new SystemRequestDetails()).getId();
}
@Override
public IIdType doUpdateResource(IBaseResource theResource) {
return getProvider(theResource).update(theResource, null, new SystemRequestDetails()).getId();
}
@SuppressWarnings("unchecked")
private <T extends IBaseResource> HashMapResourceProviderExtension<T> getProvider(T theResource) {
return switch (ourCtx.getResourceType(theResource)) {
case "Patient" -> (HashMapResourceProviderExtension<T>) myPatientProvider;
case "Observation" -> (HashMapResourceProviderExtension<T>) myObservationProvider;
case "CodeSystem" -> (HashMapResourceProviderExtension<T>) myCodeSystemProvider;
default -> throw new IllegalArgumentException("Unable to handle: " + theResource);
};
}
@Override
public FhirContext getFhirContext() {
return ourCtx;
}
}

View File

@ -0,0 +1,50 @@
package ca.uhn.fhir.storage.interceptor.balp;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.storage.interceptor.balp.FhirClientBalpSink;
import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderExtension;
import ca.uhn.fhir.test.utilities.server.RestfulServerExtension;
import org.hl7.fhir.dstu3.model.AuditEvent;
import org.hl7.fhir.r4.model.Reference;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
public class FhirClientBalpSinkTest {
@RegisterExtension
@Order(0)
private RestfulServerExtension myServer = new RestfulServerExtension(FhirVersionEnum.DSTU3);
@RegisterExtension
@Order(1)
private HashMapResourceProviderExtension<AuditEvent> myAuditEventProvider = new HashMapResourceProviderExtension<>(myServer, AuditEvent.class);
@Test
public void recordAuditEvent() {
// Setup
FhirClientBalpSink sink = new FhirClientBalpSink(myServer.getFhirContext(), myServer.getBaseUrl());
org.hl7.fhir.r4.model.AuditEvent auditEvent1 = new org.hl7.fhir.r4.model.AuditEvent();
auditEvent1.addEntity().setWhat(new Reference("Patient/123"));
org.hl7.fhir.r4.model.AuditEvent auditEvent2 = new org.hl7.fhir.r4.model.AuditEvent();
auditEvent2.addEntity().setWhat(new Reference("Patient/456"));
// Test
sink.recordAuditEvent(auditEvent1);
sink.recordAuditEvent(auditEvent2);
// Validate
myAuditEventProvider.waitForCreateCount(2);
List<String> whats = myAuditEventProvider
.getStoredResources()
.stream()
.map(t -> t.getEntity().get(0).getReference().getReference())
.toList();
assertThat(whats, containsInAnyOrder("Patient/123", "Patient/456"));
}
}

View File

@ -0,0 +1,23 @@
/**
* Issues for discussion:
* <li>
* In the BasicAudit.PatientRead profile, if the object being read is something
* in the patient compartment but not the patient (e.g. Observation) is this correct:
* the entity:data is the Observation ID, but the entity:patient is the Patient ID
* </li>
* <li>
* In the BasicAudit.PatientRead profile, what if the resource being read is
* a List with multiple patients referenced?
* Implementation currently creates multiple auditevent resources
* </li>
* <li>
* In the BasicAudit.PatientQuery profile, what if the search matched resources
* belonging to different patients? E.g. The user may have requested
* Observation?patient=123 but an MDM might implicitly widen that to include
* Patient/456 (or the client even explicitly request this behavior through
* additional parameters). Currently adding multiple entity:patient repetitions
* in a single auditevent to handle this case.
* </li>
*/
package ca.uhn.fhir.storage.interceptor.balp;

View File

@ -0,0 +1,17 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%file:%line] %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>

View File

@ -523,7 +523,7 @@ public class SearchNarrowingWithConsentAndAuthInterceptorTest {
@Search(allowUnknownParams = true) @Search(allowUnknownParams = true)
@Override @Override
public synchronized List<IBaseResource> searchAll(RequestDetails theRequestDetails) { public synchronized IBundleProvider searchAll(RequestDetails theRequestDetails) {
myRequestParams.add(theRequestDetails.getParameters()); myRequestParams.add(theRequestDetails.getParameters());
return super.searchAll(theRequestDetails); return super.searchAll(theRequestDetails);
} }

View File

@ -236,10 +236,10 @@ public interface ITestDataBuilder {
} }
} }
default IBaseResource buildResource(String theResourceType, Consumer<IBaseResource>... theModifiers) { default <T extends IBaseResource> T buildResource(String theResourceType, Consumer<IBaseResource>... theModifiers) {
IBaseResource resource = getFhirContext().getResourceDefinition(theResourceType).newInstance(); IBaseResource resource = getFhirContext().getResourceDefinition(theResourceType).newInstance();
applyElementModifiers(resource, theModifiers); applyElementModifiers(resource, theModifiers);
return resource; return (T) resource;
} }

View File

@ -253,6 +253,7 @@ public abstract class BaseJettyServerExtension<T extends BaseJettyServerExtensio
* Returns the server base URL with no trailing slash * Returns the server base URL with no trailing slash
*/ */
public String getBaseUrl() { public String getBaseUrl() {
assert myServletPath.endsWith("/*");
return "http://localhost:" + myPort + myContextPath + myServletPath.substring(0, myServletPath.length() - 2); return "http://localhost:" + myPort + myContextPath + myServletPath.substring(0, myServletPath.length() - 2);
} }

View File

@ -19,7 +19,9 @@
*/ */
package ca.uhn.fhir.test.utilities.server; package ca.uhn.fhir.test.utilities.server;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.server.IBundleProvider;
import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.provider.HashMapResourceProvider; import ca.uhn.fhir.rest.server.provider.HashMapResourceProvider;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
@ -58,6 +60,12 @@ public class HashMapResourceProviderExtension<T extends IBaseResource> extends H
myRestfulServerExtension.getRestfulServer().unregisterProvider(HashMapResourceProviderExtension.this); myRestfulServerExtension.getRestfulServer().unregisterProvider(HashMapResourceProviderExtension.this);
} }
@Override
@Search(allowUnknownParams = true)
public synchronized IBundleProvider searchAll(RequestDetails theRequestDetails) {
return super.searchAll(theRequestDetails);
}
@Override @Override
public synchronized void clear() { public synchronized void clear() {
super.clear(); super.clear();