Skip to content

Tutorial 9: Audit Trail

Pages: 2


📖 Page 1: AuditEvent Implementation

Why Audit Trail?

Required for compliance: - HIPAA (Healthcare) - GDPR (Privacy) - SOC 2 (Security)

Tracks: Who did what, when, to which resource

AuditEventDTO

Create AuditEventDTO.java:

@Data
public class AuditEventDTO {
    private String id;
    private String action; // C, R, U, D, E
    private LocalDateTime recorded;
    private String outcome; // 0=success, 4=minor failure, 8=serious failure
    private String userName;
    private String resourceType; // Patient, Practitioner, etc.
    private String resourceId;
    private String description;
}

AuditService

Create AuditService.java:

@Service
@RequiredArgsConstructor
public class AuditService {

    private final IGenericClient fhirClient;

    public void createAuditEvent(String action, String resourceType, 
                                 String resourceId, String userName) {
        AuditEvent audit = new AuditEvent();

        // Type - RESTful operation
        Coding typeCoding = new Coding();
        typeCoding.setSystem("http://terminology.hl7.org/CodeSystem/audit-event-type");
        typeCoding.setCode("rest");
        typeCoding.setDisplay("RESTful Operation");
        audit.setType(typeCoding);

        // Action: C=Create, R=Read, U=Update, D=Delete, E=Execute
        audit.setAction(AuditEvent.AuditEventAction.valueOf(action));

        // Recorded time
        audit.setRecorded(new Date());

        // Outcome: 0=success
        audit.setOutcome(AuditEvent.AuditEventOutcome._0);

        // Agent (who performed action)
        AuditEvent.AuditEventAgentComponent agent = 
            new AuditEvent.AuditEventAgentComponent();
        Reference agentRef = new Reference();
        agentRef.setDisplay(userName != null ? userName : "System");
        agent.setWho(agentRef);
        agent.setRequestor(true);
        audit.addAgent(agent);

        // Source (application)
        AuditEvent.AuditEventSourceComponent source = 
            new AuditEvent.AuditEventSourceComponent();
        Reference sourceRef = new Reference();
        sourceRef.setDisplay("FHIR PMS Application");
        source.setObserver(sourceRef);
        audit.setSource(source);

        // Entity (what was affected)
        AuditEvent.AuditEventEntityComponent entity = 
            new AuditEvent.AuditEventEntityComponent();
        Reference entityRef = new Reference(resourceType + "/" + resourceId);
        entity.setWhat(entityRef);
        Coding entityType = new Coding();
        entityType.setCode(resourceType);
        entity.setType(entityType);
        audit.addEntity(entity);

        // Save to FHIR server
        try {
            fhirClient.create()
                .resource(audit)
                .execute();
        } catch (Exception e) {
            // Log but don't fail main operation
            System.err.println("Audit failed: " + e.getMessage());
        }
    }

    // Get all audit events
    public List<AuditEventDTO> getAllAuditEvents() {
        Bundle bundle = fhirClient.search()
            .forResource(AuditEvent.class)
            .returnBundle(Bundle.class)
            .count(100)
            .execute();

        return bundle.getEntry().stream()
            .map(entry -> (AuditEvent) entry.getResource())
            .map(this::toDTO)
            .collect(Collectors.toList());
    }

    private AuditEventDTO toDTO(AuditEvent audit) {
        AuditEventDTO dto = new AuditEventDTO();
        dto.setId(audit.getIdElement().getIdPart());
        dto.setAction(audit.getAction().toCode());
        dto.setRecorded(audit.getRecorded().toInstant()
            .atZone(ZoneId.systemDefault()).toLocalDateTime());
        dto.setOutcome(audit.getOutcome().toCode());

        if (!audit.getAgent().isEmpty()) {
            dto.setUserName(audit.getAgent().get(0).getWho().getDisplay());
        }

        if (!audit.getEntity().isEmpty()) {
            AuditEvent.AuditEventEntityComponent entity = audit.getEntity().get(0);
            String ref = entity.getWhat().getReference();
            if (ref != null && ref.contains("/")) {
                String[] parts = ref.split("/");
                dto.setResourceType(parts[0]);
                dto.setResourceId(parts[1]);
            }
        }

        return dto;
    }
}

📖 Page 2: Auto-Logging & UI

Add Audit to All Services

Update PatientService methods:

@Service
@RequiredArgsConstructor
public class PatientService {

    private final IGenericClient fhirClient;
    private final PatientMapper patientMapper;
    private final AuditService auditService; // ADD THIS

    public PatientDTO createPatient(PatientDTO dto) {
        Patient fhirPatient = patientMapper.toFhirResource(dto);
        MethodOutcome outcome = fhirClient.create()
            .resource(fhirPatient)
            .execute();
        Patient created = (Patient) outcome.getResource();

        // ADD AUDIT
        auditService.createAuditEvent("C", "Patient", 
            created.getIdElement().getIdPart(), "Admin");

        return patientMapper.toDTO(created);
    }

    public PatientDTO getPatientById(String id) {
        Patient patient = fhirClient.read()
            .resource(Patient.class)
            .withId(id)
            .execute();

        // ADD AUDIT
        auditService.createAuditEvent("R", "Patient", id, "Admin");

        return patientMapper.toDTO(patient);
    }

    public PatientDTO updatePatient(String id, PatientDTO dto) {
        // ... update logic ...

        // ADD AUDIT
        auditService.createAuditEvent("U", "Patient", id, "Admin");

        return getPatientById(id);
    }

    public void deletePatient(String id) {
        fhirClient.delete()
            .resourceById("Patient", id)
            .execute();

        // ADD AUDIT
        auditService.createAuditEvent("D", "Patient", id, "Admin");
    }
}

Repeat for Practitioner, Organization, Appointment services!

Audit UI

Create AuditController.java:

@Controller
@RequestMapping("/audit")
@RequiredArgsConstructor
public class AuditController {

    private final AuditService auditService;

    @GetMapping
    public String listAuditEvents(
            @RequestParam(required = false) String action,
            @RequestParam(required = false) String resourceType,
            Model model) {

        List<AuditEventDTO> events = auditService.getAllAuditEvents();

        // Filter
        if (action != null && !action.isEmpty()) {
            events = events.stream()
                .filter(e -> action.equals(e.getAction()))
                .collect(Collectors.toList());
        }
        if (resourceType != null && !resourceType.isEmpty()) {
            events = events.stream()
                .filter(e -> resourceType.equals(e.getResourceType()))
                .collect(Collectors.toList());
        }

        model.addAttribute("events", events);
        model.addAttribute("action", action);
        model.addAttribute("resourceType", resourceType);
        return "audit/list";
    }
}

Create templates/audit/list.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Audit Trail</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
    <h1>📝 Audit Trail</h1>

    <!-- Filters -->
    <form class="row mb-3" method="get">
        <div class="col-md-3">
            <select name="action" class="form-control">
                <option value="">All Actions</option>
                <option value="C" th:selected="${action == 'C'}">Create</option>
                <option value="R" th:selected="${action == 'R'}">Read</option>
                <option value="U" th:selected="${action == 'U'}">Update</option>
                <option value="D" th:selected="${action == 'D'}">Delete</option>
            </select>
        </div>
        <div class="col-md-3">
            <select name="resourceType" class="form-control">
                <option value="">All Resources</option>
                <option value="Patient" th:selected="${resourceType == 'Patient'}">Patient</option>
                <option value="Practitioner" th:selected="${resourceType == 'Practitioner'}">Practitioner</option>
                <option value="Organization" th:selected="${resourceType == 'Organization'}">Organization</option>
                <option value="Appointment" th:selected="${resourceType == 'Appointment'}">Appointment</option>
            </select>
        </div>
        <div class="col-md-2">
            <button type="submit" class="btn btn-primary">Filter</button>
        </div>
    </form>

    <!-- Table -->
    <table class="table table-sm table-striped">
        <thead>
            <tr>
                <th>Time</th>
                <th>Action</th>
                <th>Resource</th>
                <th>ID</th>
                <th>User</th>
                <th>Outcome</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="event : ${events}">
                <td th:text="${#temporals.format(event.recorded, 'MMM dd HH:mm')}"></td>
                <td>
                    <span th:switch="${event.action}">
                        <span th:case="'C'" class="badge bg-success">Create</span>
                        <span th:case="'R'" class="badge bg-info">Read</span>
                        <span th:case="'U'" class="badge bg-warning">Update</span>
                        <span th:case="'D'" class="badge bg-danger">Delete</span>
                    </span>
                </td>
                <td th:text="${event.resourceType}"></td>
                <td th:text="${event.resourceId}"></td>
                <td th:text="${event.userName}"></td>
                <td>
                    <span th:if="${event.outcome == '0'}" class="badge bg-success"></span>
                    <span th:if="${event.outcome != '0'}" class="badge bg-danger"></span>
                </td>
            </tr>
        </tbody>
    </table>
</div>
</body>
</html>

✅ Audit Complete

  • AuditEvent DTO and Service
  • Automatic logging in all CRUD operations
  • Audit UI with filters (action, resource type)
  • Compliance ready (HIPAA, GDPR, SOC 2)

🚀 Next

System complete! Now test and deploy.

Tutorial 10: Testing & Deployment


💡 Quick Tips

Non-Blocking

Audit failures shouldn't break main operations (try-catch)

Action Codes

C=Create, R=Read, U=Update, D=Delete, E=Execute

Compliance

Audit trail is required for healthcare applications