FHIR Programming using Java and HAPI FHIR Server - Advanced Topics (including Security)

Introduction

Welcome to the continuation of my series on FHIR Programming using Java and HAPI FHIR Server. In previous articles, we've covered setting up an environment, creating, updating, deleting, searching, validating, and chaining FHIR operations. In this article, we'll delve into advanced topics such as security, auditing, and transaction management to help you build robust and secure healthcare applications.

Understanding the FHIR Security Model

Before implementing security features, it's essential to understand FHIR's comprehensive security framework and the concepts that underpin secure healthcare data exchange.

Authentication vs Authorization

These two concepts are often confused but serve distinct purposes:

Authentication - Verifying the identity of the user or system:

  • "Who are you?"
  • Typically handled via OAuth2, SAML, or OpenID Connect
  • Results in a verified identity (user, application, or system)
  • Does not determine what actions are permitted

Authorization - Determining what the authenticated entity can do:

  • "What are you allowed to do?"
  • Based on roles, permissions, patient relationships, or other criteria
  • In FHIR, often implemented via SMART scopes
  • Controls access to specific resources and operations

SMART on FHIR Scopes

SMART on FHIR defines a standardized set of OAuth2 scopes that precisely control access to FHIR resources:

Scope Format: [context]/[resource].[permission]

Context Types:

  • patient/ - Access limited to data for the specific patient in context
  • user/ - Access based on the user's permissions across patients
  • system/ - Backend service access (no user context)

Permission Types:

  • .read - Read access to the resource type
  • .write - Create/Update/Delete access
  • .* - Full access (read and write)

Examples:

  • patient/Observation.read - Read observations for the patient in context
  • user/Patient.* - Full access to Patient resources based on user permissions
  • system/MedicationRequest.read - Backend service reading medication requests
  • patient/*.read - Read all resource types for the patient in context

Launch Context

SMART on FHIR defines two launch contexts for applications:

EHR Launch - Application is launched from within an EHR:

  • EHR passes a launch parameter to the app
  • Patient and/or encounter context is provided by the EHR
  • App requests launch scope during authorization
  • Seamless integration with clinical workflow

Standalone Launch - Application runs independently:

  • App initiates the authorization flow directly
  • User may need to select a patient
  • App requests launch/patient scope to get patient selection UI
  • Suitable for patient-facing apps or administrative tools

AuditEvent Resource and ATNA Compatibility

FHIR's AuditEvent resource is designed to be compatible with the IHE ATNA (Audit Trail and Node Authentication) profile, enabling interoperability with existing healthcare audit infrastructure:

Key AuditEvent Elements:

  • type - Categorizes the event (e.g., "rest" for RESTful operations)
  • subtype - More specific event type (e.g., "create", "read", "update")
  • action - CRUD action: C (Create), R (Read), U (Update), D (Delete), E (Execute)
  • recorded - When the event was logged
  • outcome - Success (0) or various failure codes
  • agent - Who/what performed the action (user, system, device)
  • source - The system that generated the audit event
  • entity - What resources were accessed or modified

This structure maps directly to ATNA DICOM audit messages, allowing FHIR audit events to be consumed by existing healthcare audit repositories.

Provenance: Tracking Data Lineage

While AuditEvent tracks system access, Provenance tracks the origin and history of clinical data:

  • target - The resource(s) the provenance relates to
  • recorded - When the provenance was recorded
  • activity - What activity occurred (create, revise, transform)
  • agent - Who was involved in creating/modifying the data
  • entity - What the data was derived from (source documents, etc.)
// Creating a Provenance record for data created from a CDA document
Provenance provenance = new Provenance();
provenance.addTarget(new Reference("Observation/123"));
provenance.setRecorded(new Date());

// Activity: derived from a document
provenance.setActivity(new CodeableConcept().addCoding(
    new Coding()
        .setSystem("http://terminology.hl7.org/CodeSystem/v3-DataOperation")
        .setCode("CREATE")));

// Agent: the practitioner who created the data
Provenance.ProvenanceAgentComponent agent = provenance.addAgent();
agent.setWho(new Reference("Practitioner/456"));
agent.setType(new CodeableConcept().addCoding(
    new Coding()
        .setSystem("http://terminology.hl7.org/CodeSystem/provenance-participant-type")
        .setCode("author")));

// Source entity: the CDA document
Provenance.ProvenanceEntityComponent entity = provenance.addEntity();
entity.setRole(Provenance.ProvenanceEntityRole.SOURCE);
entity.setWhat(new Reference("DocumentReference/789"));

Provenance is essential for clinical decision support, regulatory compliance, and data quality initiatives.

FHIR's Consent resource manages patient privacy preferences and data sharing agreements:

  • status - active, inactive, rejected, entered-in-error
  • scope - What the consent covers (treatment, research, privacy)
  • category - Type of consent (e.g., HIPAA authorization)
  • patient - Who the consent belongs to
  • provision - Specific rules about what is permitted or denied

Consent Provisions can specify:

  • Actors who can access data
  • Specific data classes or resources covered
  • Time periods for access
  • Purposes for which data may be used
  • Security labels that trigger the consent

Implementing consent enforcement requires server-side logic that evaluates consent resources when processing requests, which is typically done at the FHIR server level rather than in client code.

Prerequisites

Before diving into advanced topics, ensure your development environment is properly set up:

  • Java Development Kit (JDK) installed and configured.
  • Apache Maven installed and your project is set up.
  • HAPI FHIR Server is running and accessible.
  • You can find all the code demonstrated in this tutorial on GitHub here

“Time is a created thing. To say ‘I don’t have time’ is to say ‘I don’t want to.’” ~ Lao Tzu

Step 1 of 3: Implementing Security in FHIR

Security is a critical aspect of any healthcare application. FHIR, being a standard for healthcare data exchange, includes several mechanisms to ensure data security, including authentication, authorization, and encryption. HAPI FHIR provides built-in security features that integrate with various authentication systems.

Authentication and Authorization

HAPI FHIR supports OAuth2 for secure access control. This allows you to manage authentication and authorization using a robust identity management platform. Below is an example of how to configure OAuth2 authentication in your Java application:

package com.saravanansubramanian.fhir;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.BearerTokenAuthInterceptor;
import org.hl7.fhir.r4.model.Patient;

public class OAuth2Client {
    public static void main(String[] args) {
        // Initialize FHIR context
        FhirContext ctx = FhirContext.forR4();
        
        // Create a bearer token interceptor with your access token
        String accessToken = "your-access-token";
        BearerTokenAuthInterceptor authInterceptor = new BearerTokenAuthInterceptor(accessToken);
        
        // Create a FHIR client
        IGenericClient client = ctx.newRestfulGenericClient("https://your-fhir-server-url");
        
        // Register the interceptor with the client
        client.registerInterceptor(authInterceptor);
        
        // Now you can use the authenticated client to access protected resources
        Patient patient = client.read()
            .resource(Patient.class)
            .withId("123")
            .execute();
        
        System.out.println("Retrieved patient: " + patient.getNameFirstRep().getNameAsSingleString());
    }
}

In this example, we create a BearerTokenAuthInterceptor with an OAuth2 access token and register it with the FHIR client. This interceptor adds the Authorization header with the Bearer token to each request, ensuring secure access to protected resources.

Data Encryption

Data encryption is crucial for protecting sensitive healthcare information. HAPI FHIR ensures that data is encrypted in transit by supporting HTTPS. Here's how you can configure your client to enforce HTTPS:

package com.saravanansubramanian.fhir;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.FileInputStream;
import java.security.KeyStore;
import java.security.SecureRandom;

public class SecureClient {
    public static void main(String[] args) throws Exception {
        // Initialize FHIR context
        FhirContext ctx = FhirContext.forR4();
        ctx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
        
        // Configure SSL context with a trusted keystore
        KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
        trustStore.load(new FileInputStream("path/to/truststore.jks"), "password".toCharArray());
        
        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        tmf.init(trustStore);
        
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, tmf.getTrustManagers(), new SecureRandom());
        
        // Set the SSL context on the client factory
        ctx.getRestfulClientFactory().setSocketFactory(sslContext.getSocketFactory());
        
        // Create a FHIR client with HTTPS URL
        IGenericClient client = ctx.newRestfulGenericClient("https://your-secure-fhir-server-url");
        
        // Add a logging interceptor to see the requests and responses
        LoggingInterceptor loggingInterceptor = new LoggingInterceptor();
        loggingInterceptor.setLogRequestBody(true);
        loggingInterceptor.setLogResponseBody(true);
        client.registerInterceptor(loggingInterceptor);
        
        // Now you can use the secure client to access resources
        System.out.println("Client configured with secure connection.");
    }
}

This code configures the FHIR client with a custom SSLContext that trusts specific certificates. This is particularly important when connecting to servers with self-signed certificates or when your organization uses its own certificate authority.

Step 2 of 3: Auditing and Logging

Auditing is essential in healthcare applications to track access and modifications to sensitive data. FHIR provides the AuditEvent resource specifically for this purpose, and HAPI FHIR makes it easy to create and manage audit logs.

Implementing Auditing

package com.saravanansubramanian.fhir;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import org.hl7.fhir.r4.model.*;

import java.util.Date;

public class AuditLogger {
    public static void main(String[] args) {
        // Initialize FHIR context
        FhirContext ctx = FhirContext.forR4();
        
        // Create a FHIR client
        IGenericClient client = ctx.newRestfulGenericClient("http://localhost:8080/fhir");
        
        // Create an AuditEvent resource
        AuditEvent auditEvent = new AuditEvent();
        
        // Set the timestamp
        auditEvent.setRecorded(new Date());
        
        // Set the outcome
        auditEvent.setOutcome(AuditEvent.AuditEventOutcome._0);
        
        // Set the action (C = Create, R = Read, U = Update, D = Delete)
        auditEvent.setAction(AuditEvent.AuditEventAction.C);
        
        // Set the type of event (AuditEvent.type is a Coding in R4)
        auditEvent.setType(new Coding()
            .setSystem("http://terminology.hl7.org/CodeSystem/audit-event-type")
            .setCode("rest")
            .setDisplay("RESTful Operation"));
        
        // Add agent information (who performed the action)
        AuditEvent.AuditEventAgentComponent agent = auditEvent.addAgent();
        agent.setWho(new Reference("Practitioner/123"));
        agent.setRequestor(true);
        
        // Add source information (the application that logged the event)
        auditEvent.getSource().setObserver(new Reference("Device/system"));
        
        // Add entity information (the resource that was acted upon)
        AuditEvent.AuditEventEntityComponent entity = auditEvent.addEntity();
        entity.setWhat(new Reference("Patient/456"));
        
        // Set detailed type information (AuditEventEntity.type is a Coding in R4)
        entity.setType(new Coding()
            .setSystem("http://terminology.hl7.org/CodeSystem/audit-entity-type")
            .setCode("1")
            .setDisplay("Person"));
        
        // Send the AuditEvent to the FHIR server
        ca.uhn.fhir.rest.api.MethodOutcome outcome = client.create()
            .resource(auditEvent)
            .execute();

        System.out.println("Audit event logged with ID: " + outcome.getId().getIdPart());
    }
}

This example creates an AuditEvent resource to log a creation action performed by a Practitioner on a Patient resource. The AuditEvent includes information about who performed the action, what action was performed, and which resource was affected.

For a more automated approach, you can implement an interceptor that automatically logs AuditEvents for each interaction with the FHIR server:

package com.saravanansubramanian.fhir;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IClientInterceptor;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.IHttpRequest;
import ca.uhn.fhir.rest.client.api.IHttpResponse;
import org.hl7.fhir.r4.model.*;

import java.util.Date;

public class AuditInterceptor implements IClientInterceptor {
    private final IGenericClient auditClient;
    private final String practitionerId;

    // Store request info captured during interceptRequest
    private final ThreadLocal<String> requestMethod = new ThreadLocal<>();
    private final ThreadLocal<String> requestUri = new ThreadLocal<>();

    public AuditInterceptor(FhirContext ctx, String fhirServerUrl, String practitionerId) {
        this.auditClient = ctx.newRestfulGenericClient(fhirServerUrl);
        this.practitionerId = practitionerId;
    }

    @Override
    public void interceptRequest(IHttpRequest request) {
        // Capture request information before it's sent
        requestMethod.set(request.getHttpVerbName());
        requestUri.set(request.getUri());
    }

    @Override
    public void interceptResponse(IHttpResponse response) throws java.io.IOException {
        // Get the captured request info
        String method = requestMethod.get();
        String uri = requestUri.get();

        // Parse resource type and ID from URI (e.g., "http://server/fhir/Patient/123")
        String resourceType = null;
        String resourceId = null;
        if (uri != null) {
            String[] parts = uri.split("/");
            for (int i = 0; i < parts.length - 1; i++) {
                if (parts[i].matches("^[A-Z][a-zA-Z]+$")) { // Resource type pattern
                    resourceType = parts[i];
                    if (i + 1 < parts.length && !parts[i + 1].contains("?")) {
                        resourceId = parts[i + 1].split("\\?")[0];
                    }
                    break;
                }
            }
        }

        // Only log successful operations
        if (response.getStatus() >= 200 && response.getStatus() < 300) {
            // Create an AuditEvent
            AuditEvent auditEvent = new AuditEvent();
            auditEvent.setRecorded(new Date());
            auditEvent.setOutcome(AuditEvent.AuditEventOutcome._0);

            // Map HTTP method to FHIR action
            if ("GET".equals(method)) {
                auditEvent.setAction(AuditEvent.AuditEventAction.R);
            } else if ("POST".equals(method)) {
                auditEvent.setAction(AuditEvent.AuditEventAction.C);
            } else if ("PUT".equals(method)) {
                auditEvent.setAction(AuditEvent.AuditEventAction.U);
            } else if ("DELETE".equals(method)) {
                auditEvent.setAction(AuditEvent.AuditEventAction.D);
            }

            // Set the event type (AuditEvent.type is a Coding in R4)
            auditEvent.setType(new Coding()
                .setSystem("http://terminology.hl7.org/CodeSystem/audit-event-type")
                .setCode("rest")
                .setDisplay("RESTful Operation"));

            // Add agent information
            AuditEvent.AuditEventAgentComponent agent = auditEvent.addAgent();
            agent.setWho(new Reference("Practitioner/" + practitionerId));
            agent.setRequestor(true);

            // Add source information
            auditEvent.getSource().setObserver(new Reference("Device/system"));

            // Add entity information if we have a resource
            String finalResourceType = resourceType;
            String finalResourceId = resourceId;
            if (finalResourceType != null && finalResourceId != null) {
                AuditEvent.AuditEventEntityComponent entity = auditEvent.addEntity();
                entity.setWhat(new Reference(finalResourceType + "/" + finalResourceId));
            }

            // Send the AuditEvent asynchronously
            new Thread(() -> {
                try {
                    auditClient.create().resource(auditEvent).execute();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }

        // Clean up thread locals
        requestMethod.remove();
        requestUri.remove();
    }
}

To use this interceptor, you would register it with your FHIR client:

// Initialize FHIR context
FhirContext ctx = FhirContext.forR4();

// Create a FHIR client
IGenericClient client = ctx.newRestfulGenericClient("http://localhost:8080/fhir");

// Register the audit interceptor
AuditInterceptor auditInterceptor = new AuditInterceptor(ctx, "http://localhost:8080/fhir", "123");
client.registerInterceptor(auditInterceptor);

// Now all operations with this client will be logged

Step 3 of 3: Managing FHIR Transactions

Transactions in FHIR allow you to bundle multiple operations into a single request, ensuring atomicity. This is particularly useful when you need to ensure that a group of related operations either all succeed or all fail.

Implementing a Transaction

package com.saravanansubramanian.fhir;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import org.hl7.fhir.r4.model.*;

import java.util.Date;
import java.util.UUID;

public class TransactionExample {
    public static void main(String[] args) {
        // Initialize FHIR context
        FhirContext ctx = FhirContext.forR4();
        
        // Create a FHIR client
        IGenericClient client = ctx.newRestfulGenericClient("http://localhost:8080/fhir");
        
        // Create a bundle with transaction type
        Bundle bundle = new Bundle();
        bundle.setType(Bundle.BundleType.TRANSACTION);
        
        // Add a Patient resource
        Patient patient = new Patient();
        patient.addIdentifier()
            .setSystem("http://hospital.example.org/patients")
            .setValue("12345");
        patient.addName()
            .setFamily("Smith")
            .addGiven("John");
        patient.setBirthDate(new Date());
        
        // Generate a UUID for this resource
        String patientUuid = "urn:uuid:" + UUID.randomUUID().toString();
        
        // Add the Patient resource to the bundle
        bundle.addEntry()
            .setFullUrl(patientUuid)
            .setResource(patient)
            .getRequest()
                .setMethod(Bundle.HTTPVerb.POST)
                .setUrl("Patient");
        
        // Add an Observation resource that references the Patient
        Observation observation = new Observation();
        observation.setStatus(Observation.ObservationStatus.FINAL);
        
        // Set the code
        CodeableConcept code = new CodeableConcept();
        code.addCoding()
            .setSystem("http://loinc.org")
            .setCode("8867-4")
            .setDisplay("Heart rate");
        observation.setCode(code);
        
        // Set the value
        Quantity value = new Quantity();
        value.setValue(80)
            .setUnit("beats/minute")
            .setSystem("http://unitsofmeasure.org")
            .setCode("/min");
        observation.setValue(value);
        
        // Reference the Patient
        observation.setSubject(new Reference(patientUuid));
        
        // Add the Observation resource to the bundle
        bundle.addEntry()
            .setResource(observation)
            .getRequest()
                .setMethod(Bundle.HTTPVerb.POST)
                .setUrl("Observation");
        
        // Execute the transaction
        Bundle responseBundle = client.transaction()
            .withBundle(bundle)
            .execute();
        
        // Process the response
        System.out.println("Transaction completed successfully");
        System.out.println("Response bundle has " + responseBundle.getEntry().size() + " entries");
        
        for (Bundle.BundleEntryComponent entry : responseBundle.getEntry()) {
            System.out.println("Resource created: " + entry.getResponse().getLocation());
        }
    }
}

In this example, we create a transaction bundle that includes both a Patient resource and an Observation resource that references the Patient. By wrapping these operations in a transaction, we ensure that either both resources are created or neither is, maintaining the consistency of our data.

Transactions are particularly useful in healthcare scenarios, such as:

  • Creating a Patient and their initial set of clinical data
  • Recording a clinical encounter with multiple observations and diagnoses
  • Updating a medication regimen with discontinuations and new prescriptions

When using transactions, it's important to remember that:

  • All operations in a transaction must succeed, or none will be applied
  • References between resources in the transaction should use UUID URIs (urn:uuid:...)
  • The server will replace these temporary UUIDs with actual resource IDs in the response
  • The order of resources in the bundle matters when there are dependencies

Conclusion

In this article, we've explored advanced topics in FHIR programming with Java and HAPI FHIR Server, focusing on security, auditing, and transaction management. These features are crucial for building secure, reliable, and maintainable healthcare applications.

By implementing proper authentication and authorization, you can ensure that only authorized users access sensitive healthcare data. With comprehensive auditing, you can track all interactions with your FHIR server, providing accountability and helping to meet regulatory requirements. And by using transactions, you can maintain data integrity across complex operations.

Stay tuned for the next article in this series, where we will continue to explore more advanced FHIR programming techniques.