Audit trail (IHE ATNA)
FerroEHR keeps a full security audit trail of API access — who did what to which resource, with what outcome, from where, and when — following the IHE ATNA (Audit Trail and Node Authentication) profile, the standard openEHR itself points at (the platform Service Model names the System Log component “IHE ATNA-compliant system log”). It is on by default: every deployment records a queryable audit trail with zero external dependencies.
The trail is orthogonal to openEHR’s own CONTRIBUTION/AUDIT_DETAILS
change-control audit (which the server always writes in the same transaction
as every version change): openEHR audit records what a version says about its
own authorship; ATNA records security surveillance of access, including
reads and rejected attempts.
The record, in both official formats
Every audited operation produces one record, rendered in the two formats the IHE standards define:
- FHIR R4
AuditEventfollowing the IHE BALP (Basic Audit Log Patterns) content profiles — the modern RESTful-ATNA form and the canonical stored form. Patient-centric operations carry the resolved EHR subject as the patient entity (PatientRead/PatientCreate/… profile claims); query executions carry the search expression; Bearer-authenticated requests record the token’sjti(and never the token itself) perOAUTHaccessTokenUse.Minimal. - DICOM Audit Message (DICOM PS3.15 §A.5 XML) — the classic ATNA form,
shipped over syslog per IHE ITI-20 when the syslog sink is enabled.
Dedicated DICOM event ids are used throughout: Patient Record (110110),
Query (110112), Export/Import (110106/110107, the EHR-Extract directions),
User Authentication (110114, with the Login
EventTypeCode110122).
Every server operation is audited (unrecognised extension operations fail
closed to a generic audited class — nothing is silently unaudited), and
authentication rejections (401/403) are always recorded, attributed to
the caller where one authenticated.
Sinks
Records fan out to independently configured sinks ([audit] in
ferroehr.toml — see the
configuration reference):
| Sink | Default | What it does |
|---|---|---|
[audit.store] | on | The local Audit Record Repository: records persist in the dedicated audit PostgreSQL schema (append-only, strictly outside the EHR content), served back via the ITI-81 search below. retention_days prunes old records hourly (0 = keep forever). |
[audit.syslog] | off | The classic ATNA feed: DICOM PS3.15 XML over syslog (RFC 5424; UDP or TLS transport) to an external ARR, per IHE ITI-20. |
[audit.fhir_feed] | off | The RESTful-ATNA feed (ITI-20 ATX: FHIR Feed): each FHIR AuditEvent is POSTed to an external FHIR ARR. With the local store on, delivery is outbox-driven: an ARR outage loses nothing, pending records ship on recovery. |
The local store is the durability anchor. Under fail_mode = "closed", a
store that stops accepting writes makes every subsequent auditable operation
answer 503 Service Unavailable until a write succeeds again — no un-audited
PHI access. (open, the default, drops-and-meters instead; every loss path
is metered — see the atna_audit_* counters in
Operations.)
Retrieving audit records (ITI-81)
The RESTful-ATNA ITI-81 Retrieve ATNA Audit Event transaction is served at the FHIR façade:
GET /ferroehr/rest/openehr/v1/fhir/r4/AuditEvent
It returns a FHIR searchset Bundle of the stored AuditEvent documents,
newest first, with the full match total. Supported search parameters:
date (ge/le-prefixed instants, repeatable), patient, agent (the
principal), entity (the resource id), outcome (0/4/8/12),
action (C/R/U/D/E), and _count/_offset paging; other FHIR
search parameters are ignored (lenient search). The surface is admin-only
under RBAC and answers 404 when the local store is disabled.
# Who accessed patient-42's data this month?
curl -u admin:pw \
"https://cdr.example.org/ehrbase/rest/openehr/v1/fhir/r4/AuditEvent?patient=patient-42&date=ge2026-07-01T00:00:00Z"
This is also the openEHR “record demerging” instrument: when data lands in the wrong EHR, the patient-filtered audit search shows exactly who read it.
Node authentication (ITI-19, mutual TLS)
ATNA’s second half is node authentication. [server.tls] terminates TLS
natively (protocol floor: TLS 1.2+, per IETF BCP 195) and can demand a
verified client certificate:
[server.tls]
enabled = true
cert_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
client_auth = "required" # off | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"
With client_auth = "required", only clients presenting a certificate
chaining to your explicit trust anchor complete the handshake — the IHE
mutually-authenticated-node posture. Deployments terminating TLS at an
ingress keep [server.tls] off and enforce mTLS there instead. The
separate-port management listener always stays plain HTTP (an internal
surface). Complete the posture with time synchronisation (IHE Consistent
Time): run NTP/chrony on every node so audit timestamps align across
systems.
Configuration summary
Auditing defaults to on with the local store only; see the
configuration reference for every
[audit] key and its FERROEHR__AUDIT__* environment form. Deployments
upgrading from the previous [atna] section: the server refuses the old
keys at boot with did-you-mean guidance — move the settings under
[audit.syslog] (host/port/transport/tls_ca_file/
tls_identity_cert_file/tls_identity_key_file) and the shared keys to
[audit].