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 the openEHR reference text points at (its platform service description names a system log “IHE ATNA-compliant”). It is on by default: every deployment records a queryable audit trail with zero external dependencies.
Warning
Two postures are legitimate to run and wrong to run silently, so the server says them at
warnon boot (structured fieldposture = "audit"), in theaudit_senderreadiness indicator and on/management/info: auditing off ([audit] enabled = false) writes no access log and leaves the EHDS logging component off, and the shipped defaultfail_mode = "open"drops a record the audit queue cannot take while the request succeeds, so an access under load can go unrecorded. A deployment that must never serve an unrecorded access setsfail_mode = "closed", which answers503andRetry-Afterinstead. A deployment declaringdeployment_profile = "production"refuses to start with auditing off.
- The record, in both official formats
- Reads are logged too, per record
- The EHDS logging elements, mapped
- Sinks
- Tamper evidence
- Retrieving audit records (ITI-81)
- Node authentication (ITI-19, mutual TLS)
- Against independent ATNA implementations
- Configuration summary
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. Both are written by the shared audit crates of the
FerroHEALTH products (FerroAUDIT): ehds-logging writes the EHDS access
record, and ihe-atna writes the BALP AuditEvent and the DICOM audit
message and sends them. FerroEHR decides which accesses are logged, what each
record says and where it is stored.
- FHIR R4
AuditEventfollowing the IHE BALP 1.1.4 (Basic Audit Log Patterns) content profiles: the modern RESTful-ATNA form and the canonical stored form. An access to clinical content is the EHDS access record:ehds-loggingwrites it on theRESTfulpattern of its action, with the patient behind the EHR as the patient entity (PatientRead,PatientCreateand the otherPatient*claims), the categories, the retention, the security labels and the origins. Every other record (an authentication, a template or party operation, a refused access) is a BALP exchange FerroEHR fills directly. The person is theagent:userslice, named by the token’s issuer and subject, with the token’sjtias itspolicy, the caller’s roles as itsroleand the client application as an agent of its own (BALP 1.1.4 §3:5.7.5.4); a Basic user is named by the account alone, with no issuer and no client. The ITS-REST operation id is a secondsubtype(urn:openehr:its-rest:operation). The object an access record reads, creates, updates or deletes is named by its openEHR object id asentity.what.identifierin the systemurn:openehr:object-id, never as awhat.reference, since it is no FHIR resource (FHIR R4 references.html §2.3.0.3). The outcome is stated inoutcomeDesc, andsource.typenames an application server. Query executions carry the search expression. - DICOM Audit Message (DICOM PS3.15 §A.5 XML): the classic ATNA form,
shipped as RFC 5424 syslog over RFC 5425 TLS per IHE ITI-20 when the syslog
sink is enabled, with the optional
EventOutcomeDescription,AuditSourceTypeCode(4, an application server) and each participant object’sParticipantObjectDataLifeCycle. 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 LoginEventTypeCode110122).
Both formats give the client and the server the same data-flow role: on a
read the server is Source (DCM 110153) and the client Destination (110152),
because the data moves from the server to the client (DICOM PS3.15 §A.5.2.7,
the BALP Read profiles); on a create, an update and a query the client is
Source (PS3.15 §A.5.3.20 for a query, the BALP Create, Update and Query
profiles).
Every server operation is audited (an unrecognised extension operation fails
closed to a generic audited class, so nothing is silently unaudited) and
access refusals are always recorded: a 401, a 403, and the 400 a
malformed Authorization header earns. A refusal is attributed to the caller
only when one actually authenticated; an unattributable denial is recorded
as unattributed rather than under a fabricated subject, so no audit record
ever names an identity that did not authenticate.
Note
Successful-login records are suppressed by default (
[audit] suppress_login_events = true). Set it tofalseto record them. Even then, a login record marks a genuine authentication event, not every authenticated request: it is emitted when credentials were actually verified: a Basic verified-credential-cache miss. A cache hit continues an established session, and a Bearer request authenticated out of band at your identity provider, so neither mints a per-request login record. Rejections are recorded regardless of this switch.
Reads are logged too, per record
A write leaves its own trail in openEHR’s contribution and audit chain. A read leaves nothing behind unless the server records it, and reads are what the access-logging rules are about: NEN 7513 requires per-access logging of who consulted which record and on whose authority, the Wabvpz gives the patient the right to know who consulted their record, and EHDS Art. 9 requires logging of access to electronic health data for primary use. No openEHR specification governs the read side, so this is FerroEHR’s own extension.
Every retrieval in the EHR, Query and Demographic APIs produces a record, and a
test walks the generated route tables to prove it: a newly generated GET that
emits nothing fails the build.
Each record additionally carries:
| Field | What it holds |
|---|---|
domain | The pseudonymisation domain read: ehr, demographic, linkage, or system for an operation that touches no domain. |
organisation | The organisation the caller acted for, read from the access token’s [authz.abac] organization_claim — the same claim the ABAC layer decides on, so no second setting can name a different one. The recording does not depend on that layer being switched on. A Basic-authenticated caller and an unconfigured claim both leave it empty. |
purpose | The purpose of use the caller declared in the x-purpose-of-use header, when the deployment accepts it. |
emergency_access | true when the declared purpose is one of [audit] emergency_purpose_codes: an emergency access (EHDS Art. 11(5)). |
legal_basis | The basis the deployment processes under, from [audit] legal_basis. |
result_count | How many records the operation served. |
request_id | The x-request-id correlation id the response carried. |
A query is the case a single record cannot describe. One AQL statement is one
operation but potentially many disclosures, so it produces its own execute
record plus one access record per EHR it served, each carrying that EHR’s
served-row count. The served set is read off the rows the caller actually
received rather than the rows the statement matched: a legal record of access
must not name an EHR whose content was never disclosed. Two query shapes carry
no per-EHR breakdown, because the shape makes it underivable —
SELECT DISTINCT, where an extra column changes which rows are distinct, and
an aggregate projection, where a per-row EHR is not valid SQL. Those record the
statement and its row count.
NEN 7513 content, field by field
NEN 7513 §5 lists what a logged event has to contain. This is where each item lives in the record:
| NEN 7513 asks for | FerroEHR records it as |
|---|---|
| The identity of the person who accessed the record | principal — the authenticated Basic username or OAuth sub; an unattributable denial is recorded as unattributed, never under a fabricated identity. With [authz.professional] configured, professional_id names the natural person when the token names a client application |
| The identity of the patient whose record was accessed | patient_id — the resolved EHR subject, which under the pseudonymisation boundary is an opaque pseudonym |
| Which record, or which part of it | resource_class + resource_id: the version uid, party uid or EHR id the operation touched |
| The date and time | recorded_at, the event time; stored_at is when the row was persisted |
| The kind of action | action (DICOM C/R/U/D/E) plus operation, the released ITS-REST operation id. It equals the served OpenAPI operationId except for the demographic CONTRIBUTION pair, served as demographic_contribution_create and demographic_contribution_get because the released contribution_create and contribution_get also name the EHR operations and one document needs unique ids |
| Whether it succeeded | outcome, the DICOM outcome indicator (0 success, 4 minor, 8 serious, 12 major) |
| Under which role or authority | roles — the roles the caller held at access time (the RFC 9068 §2.2.3.1 claim carriers for a bearer, the user definition for Basic), rendered as FHIR agent.role on the requestor and as a RoleIDCode per role on the DICOM requesting participant |
| On whose authority, and why | purpose and legal_basis, and permit, the identifier of the data permit a secondary-use caller declares it acts under (Secondary use) |
| Which system, and from where | AuditSourceID and the source participant’s network address (client_ip): the connection’s peer, or the client a peer in [server] trusted_proxies names in its forwarded_header (Server) |
Two of these are the deployment’s to supply. purpose is only as meaningful as
the vocabulary you agree with your callers: set [audit] purpose_codes to that
vocabulary and a code outside it is recorded as absent rather than as free text
that reads at review time like an established purpose. legal_basis is a
deployment-level fact and is recorded on every access once you set it.
Mapping the recorded fields onto your own retention schedule and review process stays yours: the software records the events and serves them back, and no supplier document discharges the obligation to review them.
The EHDS logging elements, mapped
Annex II 3.2 of Regulation (EU) 2025/327 lists five things the European logging software component must record “on every access event or group of events”. This is where each one lands, or does not. The elements were read from the published text on 2026-09-10; the regulation is the authority and the wording below is a paraphrase.
| Annex II 3.2 | Access-event field | DICOM PS3.15 | FHIR AuditEvent | State |
|---|---|---|---|---|
| (a) the healthcare provider or other individuals having accessed | organisation | — (no such attribute) | an agent whose who is a logical reference to an Organization | Recorded |
| (b) the specific natural person or persons having accessed | principal (+ token_id, client_ip), and with [authz.professional] configured professional_id + acting_mode | ActiveParticipant/@UserID (the professional; a client application acting for one is a second, non-requesting participant; the token subject is @AlternativeUserID when it differs) | the agent:user slice, who the token’s issuer and subject, the professional’s identifier as its ihe-otherId, and the client application as an agent of its own (BALP 1.1.4 §3:5.7.5.4) | Recorded |
| (c) the categories of data accessed | categories + category_basis + category_evidence + category_map_digest, classified by ehds-logging through the [audit.categories] map (beside resource_class, domain and result_count) | none (no such element) | the entity named ehds-categories, with one ehds-category detail per category and its basis, each id the classification read and the map digest; each category is also a meta.security label | Recorded |
| (d) the time and date of access | recorded_at | EventIdentification/@EventDateTime | recorded | Recorded |
| (e) the origin or origins of data | origins + origin_count (the FEEDER_AUDIT originating systems of the served version bodies, or the server that created them, derived at commit onto version.origins) | — (no such element) | one entity named origin per origin, its system-id detail the originating system | Recorded |
Read (a) beside (b): they are both about the accessing side, and the pair
distinguishes the organisation on whose behalf the access happened from the
natural person who made it. The principal answers (b); the organisation
column answers (a), resolved from the same access-token claim the ABAC layer
reads ([authz.abac] organization_claim) rather than from a second setting
that could name a different claim. Sharing the setting is not sharing the
switch: the organisation is recorded whether or not the ABAC gate is enabled,
because the trail describes a caller rather than deciding anything about them.
The FHIR rendering carries it as an agent of its own, a logical reference to
an Organization, with no participation code, because AuditEvent.agent.type
is optional and inventing a code would be worse than omitting one. The DICOM
rendering carries nothing: PS3.15 §A.5 defines no organisation attribute on
ActiveParticipant, and AuditEnterpriseSiteID is the reporting node’s own
site rather than the caller’s organisation. Where no claim is configured or the
caller authenticated with Basic, the field stays empty rather than being
guessed.
Element (b) asks for a natural person, and a bearer token can name a client
application instead. With [authz.professional]
configured, every record says which it was and who stood behind it:
| Field | Meaning | DICOM PS3.15 | FHIR AuditEvent |
|---|---|---|---|
acting_mode | person when the token names a natural person, client when it names a client application | the participant split below | the agent split below |
professional_id | the natural person: the person’s own claim, or the professional a client token acts for | ActiveParticipant/@UserID of the requesting participant | the ihe-otherId extension of the agent:user, typed NPI |
principal | the token subject, as before: the client application in client mode | a second ActiveParticipant with UserIsRequestor="false" and the network address; @AlternativeUserID in person mode when it differs from the professional | agent:user who.identifier, with the issuer as its system; the acting mode is its first role |
assurance_level | the eIDAS level (low, substantial, high) the token’s assurance claim maps to under [authz.assurance] | none (no such attribute) | the ihe-assuranceLevel extension of the agent:user |
assurance_value | the raw claim value the level was read from, usually acr | none | none |
PS3.15 §A.5
gives ActiveParticipant no assurance attribute, so the DICOM message and the
syslog feed carry no level. The FHIR export carries the level as the BALP
ihe-assuranceLevel extension (BALP 1.1.4 §3:5.7.5.4) and never the raw claim
value, which lives in the local store’s column only. The tamper-evidence digest covers both columns, like every other column of
the record (see Tamper evidence for records written before
the digest was versioned).
Without [authz.professional] the mode and the professional stay empty and
(b) is answered by principal, which names a client application when the token
names no person. Without [authz.assurance] levels the level stays empty;
the raw value is still recorded when the token carries the claim. A Basic
credential records none of the four.
Element (c) asks for the priority category of the data, in the sense of
Art. 14(1): patient summaries, electronic prescriptions, electronic
dispensations, medical imaging, medical test results and discharge reports,
plus any category a Member State adds in national law. AQL selects archetypes
and templates, never categories, and no openEHR specification maps the one
onto the other, so the deployment declares the mapping in
[audit.categories]: a
template id, or failing that the root archetype id, maps to a set of categories
(a priority category by its code in HL7 Europe’s
EEHRxFDocumentPriorityCategoryCS, such as Patient-Summaries or
Laboratory-Reports, a national category as its <system>|<code>), or to
none for content that holds no data of any category. While you declare no
template and no archetype, the map FerroEHR ships applies: the published CKM
templates and COMPOSITION archetypes whose data sit in one priority category
(the shipped map). Your own
map replaces it, and ehds-logging classifies each access through the map in
force.
Each record is classified when the access happens, from what it served or wrote, and never at commit, because the map is your reading of your templates and can change:
- a composition read or write is classified by the version’s template id, then by its root archetype id;
- an AQL execution is classified by the template and root archetype ids of the
versions its result page came from; where the answer carries no version (a
DISTINCTor aggregate projection, or zero rows) by the query’s positive archetype and template constraints, so an operand underNOT CONTAINSor a predicate underNOTnever counts. Every id the query constrains to must be mapped, and every content operand bound to an id, or the record isunclassifiedbeside what did map. Each per-EHR record of a query carries the categories that EHR disclosed; - a logical delete writes no content, so it is classified by the version it
deleted; a
VERSIONED_COMPOSITIONread by the composition it holds; - a
CONTRIBUTIONread, with or withoutPrefer: resolve_refs, is classified by the versions the contribution committed, a deleted version again by the version it deleted; - an EHR Extract export is classified by every version it carried, and an Extract import by the newest content of every versioned object it landed;
EHR,EHR_STATUS,EHR_ACCESS, the directory, item tags and revision histories are recorded asnone, by resource kind.
An access the map cannot classify is recorded unclassified, with the
template and archetype ids as evidence, and is never refused; the FHIR record
says why (unmapped, unbound, named-nothing). A stored category is its
<system>|<code> token. category_basis says what the classification was read
from (returned, written, queried or resource-kind; records written
before 5.3.2 carry template, archetype or query and the earlier
spellings), and category_map_digest names the map that produced it, so a
later map never rereads an old record. The map’s
digest is also on the boot line and on GET /management/info under
audit.category_map. The classification stays in the access log: it is never
written to the request log, a span or a metric label. The DICOM rendering has
no element for it (PS3.15 §A.5), so it travels in the local store and the FHIR
export only.
Records in the demographic domain carry no classification, because no priority category lives there.
Element (e) asks where the DATA came from, which is not where the request came
from. openEHR models that provenance as FEEDER_AUDIT on the content itself,
so every committed version records the distinct set of
FEEDER_AUDIT.originating_system_audit.system_id values found in its body, or
this server’s own system id when it carries none. A composition, status or
folder read then records the origins of the version it served, and an AQL
execution the union over the version rows its result page came from. The record
carries the true distinct count beside a set capped at 32, so a page drawing on
more origins than that says how many rather than looking complete. A version
committed before the column existed is assessed when it is read.
One further element, adjacent to the five: the declared purpose of use
reaches one export of the two, and only one of them could carry it. It is
stored on the record, served by ITI-81, and rendered as
agent.purposeOfUse on the
requesting person’s agent in the FHIR export. The coding carries the code
alone, with no system: the vocabulary is the one you agree with your callers
in [audit] purpose_codes, which has no published code system URI, and
attributing the code to a system it does not come from would misdescribe it.
The
DICOM Audit Message schema
of PS3.15 §A.5 defines no purpose element at all — EventIdentification,
ActiveParticipant, AuditSourceIdentification and
ParticipantObjectIdentification, and none of them carries one — so on that
side it is a limit of the format, not something this code withholds.
Note
Every row of this table is asserted by a test (
app/ferroehr/tests/it/audit_ehds_mapping.rs), including the gaps: the tests that record an absence fail the day the field arrives, so the table and the code cannot drift apart in either direction.
The common specifications the regulation’s Article 36 provides for have not been adopted. When they are, this mapping is re-verified against them rather than assumed to still hold; see the EHDS readiness page.
Emergency access (EHDS Art. 11(5))
A natural person may restrict health professionals’ access to parts of their data (EHDS Art. 8). Art. 11(5) lets a professional reach restricted data where the person’s vital interests require it, and requires such cases to be “logged in a clear and understandable format” and “easily accessible for the data subject”. Art. 9(1) counts these accesses among the ones the person is told about.
You name the purpose codes your callers declare for an emergency in
[audit] emergency_purpose_codes. Each one must also be on
[audit] purpose_codes; the server refuses to start otherwise. An access
whose declared purpose is one of them carries the emergency mark:
- The stored record: the
emergency_accesscolumn istrue. It isfalsefor every other access and for records written before the column existed. - The FHIR
AuditEvent: theentitynamedehds-emergency-accessthatehds-loggingwrites, whosedescriptionstates the mark in plain words (“Emergency access: the accessor declared that the access was necessary to protect the vital interests of the data subject (Regulation (EU) 2025/327 Art 11(5)).”) and whoseehds-emergency-purposedetails name the purposes that marked it. The declared code stays onagent.purposeOfUseand onpurposeOfEvent. A patient portal keys on the entity and shows its description. - The DICOM syslog feed: no mark. The PS3.15 schema has no purpose element.
The person reads their own log, the marked accesses included, through the subject-scoped ITI-81 retrieval.
[audit]
purpose_codes = ["TREAT", "ETREAT"]
emergency_purpose_codes = ["ETREAT"]
The mark records the declaration and lifts no restriction. A composition or
EHR restricted under GDPR Art. 18 stays refused with an emergency purpose,
because Art. 18(2) admits processing only with consent, for legal claims, for
the rights of another person or for an important public interest, and the
person’s own vital interests are not on that list. The refused attempt is
still logged with the mark. An EHDS Art. 8 restriction of access by health
professionals is different: a caller holding authz.rbac.emergency_access_role
that declares an emergency purpose reads the restricted data, and its record
carries the mark
(restriction of access).
An emergency the caller does not declare is not marked.
Retention, and who chooses it
[audit.store] retention_days decides how long the repository keeps a record:
0, the default, keeps them forever, and any other value sets the horizon.
Every record stores the first date it may be deleted on, and the reaper
deletes each record hourly on or after that date and never before it. An
access record takes its end from the retention its categories and origins
call for (Regulation (EU) 2025/327 Annex II 3.4), so two records written the
same day can end on different dates; every other record ends the day after
its date of access plus the horizon. A record written before FerroEHR stored
per-record ends is reaped by the horizon.
The default is deliberate. An access log is the evidence a data subject’s right of access is served from, and a repository that silently forgets is worse than one that grows — so the software keeps everything until an operator says otherwise, rather than choosing a horizon on their behalf.
Choosing that horizon is the deployment’s, and it is a legal question: EHDS Art. 9(2) makes the access information “available for at least three years from each date of access to the data”, and national law can ask for longer. NEN 7513 is the Dutch reference for this log and the Wabvpz for how long it must survive. The setting is per node, and one node serves one organisation, so two organisations needing different horizons run two instances.
A floor is enforced where a jurisdiction sets one. The jurisdictions a
deployment answers to are the ones its [privacy.identifier_scan] rules name,
and where one publishes a minimum retention for the access log the server
refuses to boot with a shorter horizon, naming the configured value and the
floor. 0 always passes. The floors are written in calendar years, as the law
writes them: a horizon set as [audit.store] retention_years is compared
against the years exactly, and one set as retention_days against the most
days those years can span (the day count in brackets below). The floors
registered:
| Jurisdiction | Floor | Source |
|---|---|---|
| Every EU Member State (AT, BE, BG, CY, CZ, DE, DK, EE, ES, FI, FR, GR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK) | three years from each date of access (1096 days, the most three calendar years can span) | Regulation (EU) 2025/327 (EHDS) Art. 9(2); a longer national floor wins. Iceland, Liechtenstein and Norway are not listed: EHDS reaches the EEA only through an EEA Joint Committee decision |
| NL | five years from the moment the entry is written (1830 days, the most five calendar years can span) | Besluit vaststelling bewaartermijn logging (Stcrt. 2019, 38007), under Art. 5 of the Besluit elektronische gegevensverwerking door zorgaanbieders, which binds the period to NEN 7513 |
| CH | one year, and kept apart from the processing system (366 days, the most one calendar year can span) | Datenschutzverordnung (DSV) Art. 4 Abs. 5, as amended on 1 December 2025, for the logs of a large-scale automated processing of sensitive personal data; the separate storage is the deployment’s, through the forwarding sinks |
For the Netherlands the five national years win over the three EHDS years;
Switzerland is outside the Union and keeps its one year. Of the other shipped
jurisdictions, DE, FI and SE carry the EHDS floor, while GB and NO have no
floor registered: an unknown requirement is never guessed at, and a deployment
there sets its own. Art. 9 applies from 26 March 2029 for patient summaries,
prescriptions and dispensations and from 26 March 2031 for the other priority
categories (Art. 105). The floor is enforced from this release on, so a log
kept today already meets it when Art. 9 applies.
The effective retention is on the boot line beside the audit-enabled facts and
on GET /management/info under audit.
Getting the log out
Two routes, and they answer different questions.
- ITI-81 retrieval — the audit search
serves the records back as FHIR
AuditEventresources, filtered by patient, agent, date and outcome. This is the operator’s route and the one a patient portal would build on: everything Annex II 3.2 records is reachable through it, including the purpose and the organisation, which the syslog feed’s DICOM form cannot carry. - The BALP feed — records fan out to syslog and to a FHIR feed as they are written, for a SIEM or a central Audit Record Repository.
There is no patient-facing route in the product: a portal that shows a person
who accessed their record builds on ITI-81 and authenticates the person itself.
What the product gives that portal is a grant of its own size:
authz.rbac.subject_audit_role reads the log for one subject at a time, with
the patient parameter required, so the portal never holds an admin credential
over every patient’s log (GDPR
Art. 15 with Recital 63, under which the controller “should be able to provide
remote access to a secure system” where possible; EHDS Art. 9; for Dutch
deployments Wabvpz Art. 15e). The portal’s build is the deployment’s, and the
shared-responsibility page says so.
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 and tamper-evident, 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 as RFC 5424 syslog over RFC 5425 TLS 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 outbox holds the records written while the feed is configured; with the feed off, no record is owed. Records stored before FerroEHR kept an outbox and never delivered ship once a feed runs. |
Emission never blocks the request path: a record is handed to a bounded queue
(queue_capacity) and written by a background drain.
The local store is the durability anchor. Under fail_mode = "closed", an
operation whose audit record cannot be recorded answers
503 Service Unavailable: the deployment demanded an audit trail it cannot
currently deliver, so no un-audited PHI access happens, and the same holds for
the domain-level records the linkage, subject and identifier resolutions and
the EHR-Extract transfers write. open, the default, drops-and-meters instead,
and every loss path is metered (the atna_audit_* counters in
Operations); the server announces that default at boot and on
the readiness indicator as a stated caution, because a metered loss is still a
loss. Login and rejection records never gate a response in either mode.
Tamper evidence
An audit trail that anything with the application’s database password can quietly rewrite is a log, not an accountability record. The local store is therefore tamper-evident: every record is linked into a SHA-256 hash chain maintained inside PostgreSQL, so each record commits to its predecessor and to its own content. The chain is built by the database itself, not by the server, which means it covers every writer: the per-event insert, the batched drain, and any statement typed by hand.
Each record’s digest covers every column of the record except the digest
itself (row_hash) and the two per-sink forwarding stamps
(delivered_syslog_at, delivered_fhir_feed_at). A column added by a later
release joins the digest in the same release.
The store keeps a record’s facts in columns and renders its FHIR AuditEvent
from them when the record is read (ITI-81) or delivered (the FHIR feed). What
the rendering takes from the configuration (source_id, enterprise_site_id,
server_host, value_if_missing, the store horizon, [audit.categories] and
emergency_purpose_codes) is kept once per distinct configuration in
audit.audit_render_policy, keyed by the SHA-256 of its text, and every record
names the policy it was written under. A record therefore renders the same
document after the configuration changes, and the verification below reports a
policy whose text no longer matches its digest, as it reports a changed record.
Release 5.6.0 starts on an empty audit store: its migration refuses a store
that holds records written by an earlier release, and names the remedy.
Four controls sit on top of it, and they are separate on purpose:
-
The table refuses the ordinary rewrite paths outright. The only permitted change to a stored record is the per-sink forwarding stamp; an
UPDATEof any other column, aDELETE, and aTRUNCATEare refused by the database. Retention pruning goes through the one sanctioned deletion path, which records which records it removed and what the surviving chain must link back to, so reaping does not look like tampering, and tampering does not look like reaping. -
The privileges are narrow. The runtime role may record an event, stamp it forwarded, and read the trail back. It holds nothing that can rewrite a record, remove one, or alter the chain’s own bookkeeping. That posture is only fully in force with
db.migrate = "verify"; see Operations, because a self-migrating server owns the schema and can therefore turn the enforcement off. -
Verification is a query you can run yourself. It recomputes every digest, re-walks every link, and checks both ends of the chain:
SELECT * FROM audit.verify_audit_chain();An empty result means the trail is intact. Any row names one record (its chain position, its id, when it was recorded) and what is wrong with it: content modified after it was written, a render policy changed or missing, records deleted with no retention record for the removal, or records removed from the end of the chain where no successor would have noticed.
-
The server runs the check itself, on a schedule. With the local store on, the check runs one minute after boot and then every
[audit.store] verify_interval_seconds(default3600, one hour;0turns it off). Each run verifies the records written since the newest link an earlier run found intact, and the chain head, so its cost follows the records written since rather than the size of the trail (audit.verify_audit_chain_incremental()). A full pass runs every[audit.store] full_verify_interval_seconds(default604800, one week;0leaves it to the query above) and after every run that found damage, so damage to an older record stays reported. A run that finds damage logs atERROR, one summary line with the finding count and one line per finding (the first 20), and adds the count to theatna_audit_chain_findingscounter withkind = "damage". A run that cannot verify at all counts askind = "unverifiable", because a trail that cannot be checked is never a pass. The latest result is theaudit_chainindicator onGET /health/readiness:UPwhile intact andDEGRADEDwhen damaged or unverifiable; its detail onGET /management/healthnames the first finding. It never flips readiness, because the trail keeps recording while the damage is investigated. Alert on the counter or on that component, and use the query above to see every finding. Each replica runs its own check.
Important
This is detection, not prevention, and the boundary is worth stating plainly. The chain is unkeyed, so a party with unrestricted write access to the
auditschema (the database owner, or a superuser) can delete a record and recompute every hash after it. What closes that case is keeping the trail somewhere that party does not control. Enable[audit.syslog]or[audit.fhir_feed]so records leave the box as they are written, and give the server an app-role-only DSN so it is not that party.
Retrieving audit records (ITI-81)
The RESTful-ATNA ITI-81 Retrieve ATNA Audit Event transaction is a FHIR
search on AuditEvent, so it is served at FerroEHR’s FHIR R4 base,
/ferroehr/rest/fhir/r4, beside the openEHR API rather than under it, where a
FHIR client or an IHE Audit Consumer expects [base]/AuditEvent. It is the one
FHIR route FerroEHR serves:
GET /ferroehr/rest/fhir/r4/AuditEvent
It returns a FHIR searchset Bundle of the matching records’ AuditEvent
documents, rendered from the store, newest first, with the full match total, a self link and a next link
while more pages follow. The query is read by ihe-atna, the family’s audit
crate. At least one date parameter is required (RESTful ATNA
§3.81.4.1.2.1): a query without one is a 400. Every search parameter
§3.81.4.1.2.2 says the repository shall support is applied. A parameter
with several comma-separated values matches any of them, and every
parameter of the query must match. A token takes the three forms of FHIR R4
Search §3.1.1.4.10: code, |code (recorded without a system) and
system|code (or system| for any code of that system). FerroEHR records
identifiers without a system, except the object of an access record, whose
system is urn:openehr:object-id: entity.identifier=urn:openehr:object-id|<uid>
and entity.identifier=<uid> find it, and a query that names another system
for an identifier matches nothing. Every parameter answers through an index. A
parameter on an element of the document (address and the .identifier,
entity-type, entity-role, type and subtype parameters) matches the
search tokens the store takes from the record’s rendered document when it
writes the record (audit.audit_search_token, audit.audit_search_set and
the record’s search_tokens), so the search finds what the served document
says and renders only the page it serves.
| Parameter | Matches |
|---|---|
date | required, repeatable: a FHIR date value with a prefix (eq when none, gt, lt, ge, le, sa, eb); a day such as 2026-07-12 covers the whole day, read in UTC |
patient | the recorded patient (EHR subject) id |
patient.identifier | entity.what.identifier of the entity whose role is Patient |
agent | the authenticated principal |
agent.identifier | agent.who.identifier |
entity | the touched resource id |
entity.identifier | entity.what.identifier of any entity |
entity-type | entity.type |
entity-role | entity.role |
source.identifier | source.observer.identifier, the audit source id |
type | type |
subtype | subtype (the REST interaction and the openEHR operation) |
address | a part of agent.network.address, compared without regard to case |
outcome | the outcome indicator: 0, 4, 8 or 12 (FerroEHR records 0, 4 and 8) |
action | the action code: C, R, U, D or E |
_count / _offset | paging; page size defaults to 50, capped at 1000; _count=0 returns the total alone |
A malformed value is a 400 carrying a FHIR OperationOutcome with
invalid; another FHIR R4 AuditEvent parameter, which §3.81.4.1.2.2 does
not ask for, is a 400 with not-supported; a parameter the crate does not
know is ignored
(§3.81.4.1.3). Under RBAC the
unscoped retrieval is admin-only; a caller holding the configured
authz.rbac.subject_audit_role reads it scoped to one subject: one
patient or patient.identifier with one value is required, and the caller
is refused with a 403 that names the parameter when it omits
it. Reading a subject’s log is itself recorded as an access naming that
subject and the record count served. An
emergency access in the subject’s log
carries its mark as the ehds-emergency-access entity. The surface answers 404 when the local
store is disabled.
# Who accessed patient-42's data this month?
curl -u admin:pw \
"https://cdr.example.org/ferroehr/rest/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.
Note
Audit records are themselves sensitive (they name patients, subjects and actions) so this endpoint is a PHI-disclosure surface and is authorized like any other.
Node authentication (ITI-19, mutual TLS)
ATNA’s second half is node authentication. [server.tls] terminates TLS
natively and can demand a verified client certificate:
[server.tls]
certificate_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
min_version = "1.3" # 1.3 (default) | 1.2
client_auth = "required" # none | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"
The protocol floor is TLS 1.3 only by default, as RFC 9325 §3.1.1 has it
supported. Setting min_version = "1.2" enables
1.2 alongside 1.3, never instead of it, for a client that genuinely cannot
do 1.3, such as an older integration engine or a pinned Java runtime. TLS 1.1
and 1.0 are refused when the configuration is read (RFC 8996).
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 leave [server.tls] unset and enforce mTLS there instead.
Warning
The separate-port management listener (
management.port) stays plain HTTP even with[server.tls]set, and it binds all interfaces. Treat it as an internal surface and keep it off any publicly routed port. Underdeployment_profile = "production"setting it opens theplaintext_listenergap, so the server refuses to start unless the gap is accepted by name.
Complete the posture with time synchronisation (IHE Consistent Time): run NTP/chrony on every node so audit timestamps align across systems.
Against independent ATNA implementations
The transactions above are tested by FerroEHR’s own suites, which read the
profiles the way FerroEHR’s authors read them. EHDS Annex II 1.4 asks that a
component meant to work with other products does so reliably, so
scripts/interop/atna.sh
runs the quickstart against software FerroEHR’s authors did not write, each
from a published image:
| Transaction | Independent implementation | What the run requires |
|---|---|---|
| ITI-20 FHIR feed | HAPI FHIR, an open-source FHIR R4 server, as the Audit Record Repository, with IHE BALP 1.1.4 loaded | It accepts every record, and its validator finds no error against base R4 and the BALP profile each record names |
| ITI-20 syslog | syslog-ng over RFC 5425 TLS | Every message parses as RFC 5424, and its payload is a well-formed DICOM PS3.15 A.5.1 AuditMessage (libxml2) |
| ITI-81 | HAPI FHIR’s AuditEvent search over the records the feed delivered | The same searches answer the same records on both |
cargo build --release -p loadtest
bash scripts/interop/atna.sh ghcr.io/ferrohealth/ferroehr:<tag> \
ghcr.io/ferrohealth/ferroehr-postgres:<tag> atna-out
It prints every disagreement and exits non-zero on any. A disagreement is either FerroEHR’s or a shared crate’s defect, filed and fixed, or the other side’s, recorded with the profile text that shows it.
The run of 2026-10-29 found no disagreement: HAPI FHIR accepted and validated all 71 records of the window, syslog-ng parsed all 72 messages, and the ten ITI-81 searches answered the same records on both repositories (the record).
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. The syslog sink’s
own keys are host / port / tls_ca_file /
tls_identity_cert_file / tls_identity_key_file under [audit.syslog]; the
switches shared by every sink stay directly under [audit].
Note
There are no alternative spellings for any of these: an unrecognized
FERROEHR_*variable or an unknown TOML key is a boot error naming the spelling it should have had, never a silently ignored setting. Validate a deployment’s configuration up front withferroehr config check.