Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 warn on boot (structured field posture = "audit"), in the audit_sender readiness indicator and on /management/info: auditing off ([audit] enabled = false) writes no access log and leaves the EHDS logging component off, and the shipped default fail_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 sets fail_mode = "closed", which answers 503 and Retry-After instead. A deployment declaring deployment_profile = "production" refuses to start with auditing off.

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 AuditEvent following 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-logging writes it on the RESTful pattern of its action, with the patient behind the EHR as the patient entity (PatientRead, PatientCreate and the other Patient* 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 the agent:user slice, named by the token’s issuer and subject, with the token’s jti as its policy, the caller’s roles as its role and 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 second subtype (urn:openehr:its-rest:operation). The object an access record reads, creates, updates or deletes is named by its openEHR object id as entity.what.identifier in the system urn:openehr:object-id, never as a what.reference, since it is no FHIR resource (FHIR R4 references.html §2.3.0.3). The outcome is stated in outcomeDesc, and source.type names 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’s ParticipantObjectDataLifeCycle. 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 EventTypeCode 110122).

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 to false to 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:

FieldWhat it holds
domainThe pseudonymisation domain read: ehr, demographic, linkage, or system for an operation that touches no domain.
organisationThe 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.
purposeThe purpose of use the caller declared in the x-purpose-of-use header, when the deployment accepts it.
emergency_accesstrue when the declared purpose is one of [audit] emergency_purpose_codes: an emergency access (EHDS Art. 11(5)).
legal_basisThe basis the deployment processes under, from [audit] legal_basis.
result_countHow many records the operation served.
request_idThe 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 forFerroEHR records it as
The identity of the person who accessed the recordprincipal — 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 accessedpatient_id — the resolved EHR subject, which under the pseudonymisation boundary is an opaque pseudonym
Which record, or which part of itresource_class + resource_id: the version uid, party uid or EHR id the operation touched
The date and timerecorded_at, the event time; stored_at is when the row was persisted
The kind of actionaction (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 succeededoutcome, the DICOM outcome indicator (0 success, 4 minor, 8 serious, 12 major)
Under which role or authorityroles — 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 whypurpose 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 whereAuditSourceID 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.2Access-event fieldDICOM PS3.15FHIR AuditEventState
(a) the healthcare provider or other individuals having accessedorganisation— (no such attribute)an agent whose who is a logical reference to an OrganizationRecorded
(b) the specific natural person or persons having accessedprincipal (+ token_id, client_ip), and with [authz.professional] configured professional_id + acting_modeActiveParticipant/@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 accessedcategories + 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 labelRecorded
(d) the time and date of accessrecorded_atEventIdentification/@EventDateTimerecordedRecorded
(e) the origin or origins of dataorigins + 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 systemRecorded

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:

FieldMeaningDICOM PS3.15FHIR AuditEvent
acting_modeperson when the token names a natural person, client when it names a client applicationthe participant split belowthe agent split below
professional_idthe natural person: the person’s own claim, or the professional a client token acts forActiveParticipant/@UserID of the requesting participantthe ihe-otherId extension of the agent:user, typed NPI
principalthe token subject, as before: the client application in client modea second ActiveParticipant with UserIsRequestor="false" and the network address; @AlternativeUserID in person mode when it differs from the professionalagent:user who.identifier, with the issuer as its system; the acting mode is its first role
assurance_levelthe 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_valuethe raw claim value the level was read from, usually acrnonenone

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 DISTINCT or aggregate projection, or zero rows) by the query’s positive archetype and template constraints, so an operand under NOT CONTAINS or a predicate under NOT never counts. Every id the query constrains to must be mapped, and every content operand bound to an id, or the record is unclassified beside 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_COMPOSITION read by the composition it holds;
  • a CONTRIBUTION read, with or without Prefer: 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 as none, 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_access column is true. It is false for every other access and for records written before the column existed.
  • The FHIR AuditEvent: the entity named ehds-emergency-access that ehds-logging writes, whose description states 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 whose ehds-emergency-purpose details name the purposes that marked it. The declared code stays on agent.purposeOfUse and on purposeOfEvent. 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:

JurisdictionFloorSource
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
NLfive 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
CHone 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 AuditEvent resources, 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):

SinkDefaultWhat it does
[audit.store]onThe 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]offThe 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]offThe 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 UPDATE of any other column, a DELETE, and a TRUNCATE are 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 (default 3600, one hour; 0 turns 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 (default 604800, one week; 0 leaves 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 at ERROR, one summary line with the finding count and one line per finding (the first 20), and adds the count to the atna_audit_chain_findings counter with kind = "damage". A run that cannot verify at all counts as kind = "unverifiable", because a trail that cannot be checked is never a pass. The latest result is the audit_chain indicator on GET /health/readiness: UP while intact and DEGRADED when damaged or unverifiable; its detail on GET /management/health names 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 audit schema (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.

ParameterMatches
daterequired, 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
patientthe recorded patient (EHR subject) id
patient.identifierentity.what.identifier of the entity whose role is Patient
agentthe authenticated principal
agent.identifieragent.who.identifier
entitythe touched resource id
entity.identifierentity.what.identifier of any entity
entity-typeentity.type
entity-roleentity.role
source.identifiersource.observer.identifier, the audit source id
typetype
subtypesubtype (the REST interaction and the openEHR operation)
addressa part of agent.network.address, compared without regard to case
outcomethe outcome indicator: 0, 4, 8 or 12 (FerroEHR records 0, 4 and 8)
actionthe action code: C, R, U, D or E
_count / _offsetpaging; 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. Under deployment_profile = "production" setting it opens the plaintext_listener gap, 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:

TransactionIndependent implementationWhat the run requires
ITI-20 FHIR feedHAPI FHIR, an open-source FHIR R4 server, as the Audit Record Repository, with IHE BALP 1.1.4 loadedIt accepts every record, and its validator finds no error against base R4 and the BALP profile each record names
ITI-20 syslogsyslog-ng over RFC 5425 TLSEvery message parses as RFC 5424, and its payload is a well-formed DICOM PS3.15 A.5.1 AuditMessage (libxml2)
ITI-81HAPI FHIR’s AuditEvent search over the records the feed deliveredThe 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 with ferroehr config check.