Repository navigation
0.0.8
Security
-
The trace export records nothing of a client's
traceparentor
tracestate. The request span recorded the client's trace id and span id
as a span link, and a client chooses its trace id freely, so a patient
identifier hex-encoded in it reached the operator's trace collector. The
gateway now reads neither header: it starts its own trace for every
request, as before, and every trace id and span id an exported span
carries is one it generated (#446). -
A node is never sent a request without its configured onward credential,
and never the caller's ownAuthorizationheader. -
The
errora client sees for a node no token could be obtained for is a
fixed sentence and, when the token endpoint refused with one, its
registered RFC 6749 §5.2 code. The token endpoint's description, its
address and the network error go to the log alone, and the assertion and
the token go nowhere. -
A token endpoint URL with a user name, a password, a query or a fragment is
refused at load, so no secret can ride in it into a log. -
No request reaches a node, a cross-reference service or a store before its
caller is verified. A token signed withnoneor an HMAC, of another
typthanat+jwt, from an untrusted issuer, for another audience, or
outside its validity window is refused, and an issuer that cannot be asked
fails closed with503. Apatient/scope admits nothing, because the
gateway cannot bind it to the token's patient context, and a
system/aql-*scope counts only for a listed backend client. The
DEMOGRAPHIC API admits only the clients an issuer lists in
demographic_clients. The client's token is never forwarded to a node. -
Outside
profile = "development",serve,config check,
admission checkand every reload refuse a credential sent to a URL that
is nothttps, naming the key of the URL and of the credential: a registry
endpoint with a[credentials]section, anoauth2token endpoint, a PIX
Manager with credentials, and ametrics.otlp_endpointcarrying a user
name or a password. A reload keeps the profile the process started with.
Under the development profile the same configuration starts, and the
startup banner, aWARNlog line andconfig checkname each credential
that travels unencrypted, by key, an XCPD responding gateway's XUA
assertion included (#402). -
A PIX Manager and an XCPD responding gateway are sent patient identifiers,
so outside the development profile each must behttps, with or without
a credential configured. Under the development profile they are named in
the banner, the log andconfig checklike a cleartext credential (#402). -
A reload whose file changes
profileis refused with classprofile, and
the running configuration stays, so nothing the development profile admits
can enter a process that started under another profile. A reload under
another profile could admit anhttpXCPD gateway before (#402). -
One module holds every transport rule: a URL a credential or a patient
identifier is sent to must behttpsoutside the development profile,
and a key set or introspection endpoint the gateway verifies callers
against must behttps, orhttpto loopback, under every profile (#402). -
Outside the development profile, the stored-query store's PostgreSQL
connection string must setsslmode=requirewhen it carries a password
to a networked host:disableandprefer, the driver's default, which
falls back to no TLS, are refused namingstored_queries.urlby
config check, the start and a reload, even though the store itself
changes only on a restart. Under the development profile it starts and is
named in the banner, the log andconfig check(#416). -
No request reaches a node without the caller's identity: one that reaches
the dispatcher with no verified caller is answered500and nothing is
sent. The conveyed token never carries a patient identifier (§5.4.1, N33):
it holds no IUAperson_id, and the outbound gate reads every caller
claim, refusing with nothing sent a request whose claims would carry the
identifier its query was resolved on. A client's own
openEHR-federation-clientheader never reaches a node.
Added
-
Mutual TLS to the PIX Manager, the PDQm Supplier, the PMIR Patient
Identity Registry and the mCSD directory:[[pixm.manager]],[pdqm],
[pmir]and[registry.mcsd]takeclient_identity(or
client_identity_file) andtrust_roots_file, as[xcpd]does, the
client identity held as a secret. A file that cannot be read is refused
naming its key, and material that is no PEM refuses the federation
without quoting it. The testkit gains a mutual-TLS front that puts any
harness server behindhttpswith client authentication (#507). -
A FAPI 2.0 grant as an onward credential, for a node whose authorization
server follows the FAPI 2.0 Security Profile, as the BgZ/eOverdracht
track of the Dutch binding does (Annex B §B.4a): a
[credentials."<id>".fapi2]section names the server's issuer, and the
gateway reads its RFC 8414 metadata once, holds it to the issuer and to
everything the grant sends, authenticates with an ES256
private_key_jwtassertion whoseaudis the issuer, binds every token
with DPoP, and sends the configured RFC 9396authorization_details,
taking only a token response that states the details granted. Token
exchange per verified caller works as foroauth2. The client key's
public half is published in the gateway's JWK Set. The authorization
code grant, which needs a user agent, refuses the configuration. The
testkit has a harness FAPI 2.0 authorization server, and the book has an
Onward credentials page (#497, #88). -
assertion_audience = "issuer"in anoauth2section makes every client
assertion name the authorization server's issuer, set inissuer, as its
audin place of the token endpoint (RFC 7523 §3; FAPI 2.0 §5.3.2.1).
The token endpoint stays the default (#497). -
nl-generic-functions0.0.10 adds theoauth-metadatafeature: the RFC
8414 issuer identifier and metadata checks the Nuts grant built, now
shared by both Annex B authentication tracks (#497). -
The PDQm demographics step of Annex A §A.2, ahead of localization and
resolution. With[pdqm], a patient named in a namespace the table lists
is taken to a PDQm Patient Demographics Supplier first, by ITI-78 (an
identifiersearch that asks for the master domain only) or, where the
deployment declarestransaction = "iti-119", by the ITI-119$match
withonlyCertainMatches. The identifier the one matched Patient carries
in the master domain is localized and resolved in place of the client's,
and the outbound gate withholds both from every node request. No match
leaves every membernot-resolvedand fails nothing (N6); several
matches, two master identifiers or an uncertain match refuse the
resolution and fail the query424, and the gateway never picks one. A
Supplier outage follows the localization failure policy (§14.1). The
Supplier is reached overhttpsoutside development, with no redirect,
a bounded answer and its own timeout, whichOPTIONS {base}/declares as
timeout.demographics_ms; each exchange is audited through[audit]as
the PDQm Query or Match Consumer record, required outside development;
the step shows asdemographicsonGET /health/dependenciesand is
counted inferrofed_demographics_requests_total. The read of an EHR by
subject takes the same step, and a reload applies a change to[pdqm]
as it does to[pixm](#487). -
The ITI-119 Patient Demographics Match in
ihe-iti0.0.19:
PdqmClient::match_patientposts aMatchInputto[base]/Patient/$match
and reads the Match Output Bundle, every matched Patient with its score
andmatch-grade, and its PDQm Match Consumer audit record (#487). The
testkit gains a harness PDQm Supplier that answers ITI-78 and ITI-119 from
synthetic Patients in theurn:oid:2.999example arc. -
The Nuts grant of the Dutch Generic Functions (Annex B §B.4) as an onward
credential: a[credentials."<id>".nuts]section makes the gateway obtain
that node's token from its authorization server with a Verifiable
Presentation of the gateway's own credentials, signed with itsdid:web
key, by the VP Token Grant Type of Nuts RFC021. The token is always bound
to the section'sdpop_key_filewith DPoP, and every request to the node
carries a proof of that key. The gateway checks the authorization server's
metadata and Presentation Definition before any credential leaves, sends
no expired credential, answers a demanded DPoP nonce once with a new
presentation, caches the token until 30 seconds before it expires, and
never logs or reports a credential, the presentation or the token. The
client isnl-generic-functionsfeaturenuts-auth, and the testkit has a
harness Nuts node (#88). -
The specifications the Annex B authentication tracks cite are vendored:
the GF-Authentication pages of the NL-GF IG, Nuts RFC003, RFC021 and
RFC022, nine OAuth RFCs, VC Data Model 1.1, DID 1.0, DID Resolution,
Bitstring Status List and the did:web method, and DIF Presentation
Exchange 2.0.0. The OpenID FAPI 2.0, OpenID4VCI and OpenID4VP documents
are pinned and fetched, never committed (#88). -
Mitz as the consent pre-filter of the Dutch binding (Annex B §B.6,
N27a), configured under[nl_gf.mitz]. Before resolution the gateway asks
Mitz's closed authorization question once for each candidate's care
provider: may this data holder make the patient's data of the configured
categories available to the verified caller, forTREATorCOC? The
caller's professional (UZI number and role) and organisation (URA and
type) come from the token claims each trusted issuer maps under
[auth.issuer.requester]; a caller whose token lacks them is not asked
about, and nothing is filtered for it. A member whose holder Mitz denies
for every category is
consent-denied, never asked, and clearscompletewhile the query
succeeds; every other member is asked and its node still checks consent
(N27, §14.3).Indeterminate, a fault, a timeout or a malformed answer is
no decision: those members are asked and the failure is carried in
meta.federation.consent.error, and a pre-filter that denied some members
and failed for others is counted aspartialin
ferrofed_consent_prefilter_requests_total. Mitz is asked by BSN, so a
patient named by the pseudonymised BSN gets no signal.OPTIONS {base}/
declares the pre-filter asnl-gf-mitz. The client is the newmitz
feature ofnl-generic-functions0.0.8, which follows the VZVZ
Implementatiehandleiding Open en gesloten autorisatievraag 3.8.2: SOAP 1.2
with an XACML 3.0 decision query over mutual TLS. VZVZ states no licence
for the document, soscripts/vendor/mitz.shpins it by sha256 and
fetches it into an ignored directory (#475). -
Every IHE transaction the gateway makes or receives is audited as its
profile requires. The PIXm ITI-83 query, the mCSD ITI-90 search and
ITI-91 history, the PMIR ITI-93 message received and each ITI-94
subscription create, read and delete are recorded as their profile's BALP
AuditEventand, under the new[audit]table, posted to an ATNA Audit
Record Repository over the FHIR Feed of ITI-20 (destination = "repository") or written to theferrofed::auditlog target without a
patient identifier ("log"). Each record is stored in a spool before it
is delivered: a repository that is down delays the records, one that
refuses a record has it quarantined, and a record the spool cannot store
fails its transaction closed, as the ITI-55 audit does: an ITI-83 query
fails424, a directory read is refused, an ITI-93 message is answered
503unapplied. The repository shows onGET /health/dependenciesas
audit_feed, and the identity Registry's fault can beaudit-failed.
ihe-iti0.0.18 adds thebalpfeature, with an audited mode for its
PIXm, PDQm, mCSD and PMIR clients. BALP 1.1.4, the RESTful ATNA
supplement, the ITI-20 page and each profile's audit profiles are vendored
(#486, #469). -
The registry keeps the identifiers a care services directory publishes
for each organisation (mCSDOrganization.identifier), and the NVI
localizer reads its custodian map from them: each member organisation's
URA, by the LRZa rules of Annex B §B.2, maps to the members it operates,
so a Dutch directory needs no[nl_gf.nvi.custodians]table. A table
given beside it must agree, a member no URA maps to is refused, and a
reload or directory refresh rebuilds the map.nl-generic-functionsadds
lrza::ura_in, andihe-itireads every identifier of a directory
Organization(#87). -
[nl_gf.nvi]localizes undirected patient queries through the NVI, the
national localization index of the Dutch Generic Functions (Annex B
§B.1). The client names the patient by a pseudonymised BSN; the NVI's
care providers, by URA, map to the members that hold their data, and
every other member isnot-localized. A patient named in any other
namespace, an NVI that refuses, answers outside the IG or stays silent
fails the query closed. The URL ishttpsoutside development, the
section is refused beside[xcpd]and underask-all, and
OPTIONS {base}/declareslocalization.mode = "nl-gf-nvi". The
pseudonymised walkthrough of Annex B §B.7 runs end to end in the test
harness, and no node receives the pseudonym (#87). -
The Dutch Generic Functions binding of Annex B starts in
nl-generic-functions: featurenviasks the national localization index
which care providers, by URA, hold data for a patient named by a
pseudonymised BSN, and refuses every answer that is not asearchsetof
localization records about that patient; featurelrzareads the URA of
an NL-GFOrganization. The source of the IG (fhir.nl.gf0.3.0,
EUPL-1.2) is vendored underdocs/specs/nl-gf/by a new fetch script,
pinned by tag and commit because the IG has no registry package (#87). -
The PMIR identity feed,
[pmir]: the gateway subscribes at an IHE PMIR
Patient Identity Registry with ITI-94 and servesPOST {base}{path}for
the ITI-93 messages it sends. A message is applied only with the bearer
token agreed with the Registry out of band, read fromfeed_token_file,
and only when it holds to the PMIR profiles; otherwise it is answered401,
400or415and changes nothing. An applied merge or delete drops the
resolution bindings of theehr_ids it carries in a member'sehr_id
domain, and an update, or a change that names no memberehr_id, drops
every binding. The log and the newferrofed_identity_feed_messages_total
counter carry the change kinds, counts and results, never an identifier.
GET /health/dependenciesreports the Registry asidentity_registry, and
the subscription is found by search before it is created, so a create
answered late is adopted and never made twice; one inerrororoff, or
one the Registry lost, is replaced; failed checks back off up to 32 times
check_interval_s; a create answered with no usableLocationstops
further creates and shows asidentity_registry_fault = "unmanageable";
and the drain stops the loop before it deletes the subscription.
The Registry URL and the callback URL arehttpsoutside the development
profile. Theihe-iticrate gains thepmirfeature: the ITI-94
subscriber and the ITI-93 feed reader and response, held to the PMIR 1.6.0
package, now vendored underdocs/specs/ihe-pmir/; the testkit gains a
harness Patient Identity Registry. Track 8 stays deferred (#147). -
[auth.issuer.patient]honours one issuer'spatient/grants, off by
default. It binds the issuer to the one member whose platform issues its
patient tokens and names the identifier system under which the
cross-reference knows that member'sehr_ids;config checkrefuses a
binding whose endpoint the registry lacks, or that has no registry or no
cross-reference. A bound issuer'spatient/scope counts on an EHR's data
alone. The gateway reads the token'sehrIdclaim (SMART on openEHR),
resolves it at every member (§5.2), and sends a request only to the
patient's own{node, ehr_id}pairs. Another patient, a query that names
no patient, anehr_idthe pairs do not place, EHR creation and
definitions are403(patient-confinement) with nothing sent, nothing
probed and, for another patient's read by subject, no resolution binding
or index entry recorded. A query whoseFROMreads a class beside its
one scopedEHR(underANDorOR, a secondEHR, anEHRunder
NOT CONTAINS) is refused the same way, and a named patient is checked at
the bound member alone before any localizer, consent pre-filter or other
member hears of it.openehr-federation0.0.38 adds
Analysis::within_one_ehr, which says whether every class a query reads is
contained under that oneEHR. A token whose resource scopes are allpatient/
is refused the DEMOGRAPHIC API the same way, bound or not and even for a
listed demographic client. A token without the claim is403
(patient-context-missing), and a cross-reference that cannot resolve the
patient is424(patient-context-unavailable). The bareehrIdis never
compared across members (§12.5). Each node'sopenEHR-federation-client
token carries the patient'sehr_idat that node asehrId, and only the
coveringpatient/scopes inscope(N26); the outbound gate refuses,
with nothing sent, a request whose token would tell its node another
ehr_idthan the one the request is composed for. Without the section a
patient/grant still admits nothing (#443). -
The texts client authentication is held to are vendored under
docs/specs/. The ITS-REST Release-1.1.0 fetch now takes the SMART on
openEHR source (docs/smart_app_launch/), recording that the release
declares it DEVELOPMENT status. A new fetch script takes the IHE IUA
supplement, Revision 2.5 Trial Implementation (ITI-71, ITI-72), which IHE
publishes under CC-BY-4.0. The citations in the authentication code and
the security handoff of the architecture point at the vendored text and
name the DEVELOPMENT status, and a test holds every cited section,
quotation and claim name to the pinned text (#414). -
[telemetry] trace_sample_ratiosets the share of client requests whose
spans the trace export sends, from0.0to1.0,1.0unless set, and
refused at load outside that range. The decision is made once at the
request span, from the gateway's own trace id, and the whole span tree
follows it; a node request of a trace not kept still carries a
traceparent, with its sampled flag off. The export over the OTLP wire is
tested end to end against an in-process OTLP collector in the testkit: a
serveprocess's spans arrive under the gateway's resource, are flushed
on the drain, and carry no synthetic patient identifier (#437). -
Onward token exchange per verified caller (RFC 8693, #439): an
oauth2
section withgrant = "token_exchange"and a requiredresource(RFC
8707) exchanges the caller's verified access token at the node's token
endpoint, with an assertion of the gateway as the actor and only the
caller's granted scopes that cover the operation (N26). Every exchange
carries its scope and resource; an operation no scope covers, or a
caller the edge mode asserted, fails that nodenode-errorwith nothing
sent. Exchanged tokens are cached per endpoint, caller token and scope
(the caller token hashed), at most 1024, and dropped on a401. The
gateway's own requests use the client-credentials grant at the same
token endpoint. The gate keeps the caller's token only while some node
exchanges it, and no log, span, metric oropenEHR-federation-client
claim carries it. -
DPoP-bound onward tokens per endpoint (RFC 9449, #439): anoauth2
section'sdpop_key_file, a P-256 or P-384 key, binds that node's tokens
to the key. Every request to the node and its token endpoint carries a
proof binding its method and URL, and the token's hash on the node
request; the token is sent under theDPoPscheme, a bearer token
answered to such a grant fails the node, and a nonce either server
demands is answered once. -
The gateway exports its own spans as OpenTelemetry traces over OTLP when
[telemetry] otlp_endpointnames anhttp://collector; off by default.
A federated query is one trace: the request, the resolution, the fan-out,
the merge, and onenode_requestspan per member asked, with the probe,
the template, stored-query and drift fan-outs, the localizer and the
consent pre-filter as spans of their own. Every span attribute is a route
template, a registry id, an ITS-RESToperationId, a status or a count,
never a patient identifier, query text, header value or body, and no log
event is exported. Every client request starts a trace of the gateway's
own with a random trace id, and each node request carries a W3C
traceparentfrom its own span in that trace. A client'straceparent
andtracestateare never read, so no trace id a client chose reaches a
node or an exported span (#353, #446). -
The resolution bindings of §12.5.1 step 2 are kept per verified caller,
the token's issuer, subject and client together. A federated query and a
read of an EHR by subject record where eachehr_idresolved, and a
routed request by the same caller is routed by them before theehr_id
index; another caller never sees them. A consent denial drops the
caller's bindings naming the denied member (N27a). They live in memory for
federation.binding_ttl_msand number at mostfederation.binding_capacity
(100000 by default), andOPTIONS {base}/names the step in
its_rest.ehr(#412). -
The registry can be read from an IHE mCSD 4.0.0 care services directory
and kept in step with it (#86; §15.1, N21, Annex A.5).[registry.mcsd]
names the directory's FHIR base, optional bearer or basic credentials, a
refresh interval, a deadline for one whole read or refresh, and caps on
its pages, bytes and entries (by default 30 seconds, 200 pages, 64 MiB
and 50,000 entries), in place ofregistry.document.
At start the gateway reads theOrganizations andEndpoints that carry
the registry's FHIR-form identifiers with ITI-90, and every interval it
asks for the changes since the last read with ITI-91_history?_since=,
from the directory's own clock. The content passes the checks of the FHIR
form and of a reload: an endpoint relying onhl7-fhir-rest(§15.2, N19),
a missing managing organisation (N20) or a duplicate id orsystem_id
stops the start, and a refresh that breaks one is refused with the running
registry kept, logged and counted as a refused reload. A refresh that runs
past its deadline or a cap is refused and counted the same way
(registry-budget), so a faulty or hostile directory can neither hold the
gateway nor fill its memory, and a partial answer never becomes the
registry. A directory that cannot be reached keeps the running registry,
and every outcome shows onGET /health/dependenciesasdirectory.
config checkreads the directory too. Credentials for the directory
travel overhttpsonly, outside the development profile (#402).
A member organisation of a shared directory may list endpoints that are no
member: another service's, one that lost the federation's identifier, or
one the directory deleted. Such a listing is ignored and logged by its
reference, once per read of the content, at a start and on a refresh
alike, where the document refuses it, and a deleted endpoint leaves the
registry (#423, #433). -
ihe-iti0.0.13: themcsdfeature carries the ITI-90 Query Client and
the ITI-91 Update Client (McsdClient), every walk bounded by one
Budget(a deadline and caps on pages, bytes and entries), and a replica
of a directory'sOrganizationandEndpointresources kept in step
with ITI-91 (Replica). IHE mCSD 4.0.0 is vendored underdocs/specs/ihe-mcsd/by
scripts/vendor/ihe-mcsd.sh, and the testkit carries a harness directory
built from the IG's examples. -
The ITI-55 audit messages sent to an ATNA Audit Record Repository (#418;
ITI TF-2 §3.20, §3.55.5.1.1):[xcpd] audit = "repository"with
[xcpd.audit_repository]sends each exchange's DICOM PS3.15 audit
message as ITI-20 Record Audit Event, RFC 5424 syslog with the PRI<85>
and the MSGIDIHE+RFC-3881over TLS (RFC 5425,tls://;tcp://under
profile = "development"only). Every message is fsynced to a bounded
spool before it counts as recorded and delivered from there in order, so a
repository outage delays delivery and fails no discovery (§3.20.4.1.1),
and a restart keeps the spool. A full or unwritable spool fails the
discovery closed. The spool directory is created0700with0600
files, and a directory open to other users refuses to start;
spool_diris required outside development, and[xcpd] home_community
is required withaudit = "repository", since the audit message names
the gateway'shomeCommunityID(§3.55.5.1.1). Recording only writes to
the spool. Delivery is bounded at every step (connect_timeout_msfor the
connection,send_timeout_msfor the TLS handshake and each write and
flush), and a failure drops the connection and retries with a jittered
exponential backoff up toretry_max_ms. A spooled message that cannot be
read or is no whole frame is moved to the spool'squarantine/, logged by
its sequence number, and the drain goes on; a file the gateway did not
write refuses the start, naming the file to move.
GET /health/dependenciesreportsaudit_repository(degradedwhile
retrying, while messages wait and while any sits in quarantine), and the
metricsferrofed_audit_spool_events,ferrofed_audit_spool_bytes,
ferrofed_audit_quarantined,ferrofed_audit_delivered_totaland
ferrofed_audit_retries_totalcarry no label. Where the audit messages go
takes a restart. -
ihe-iti0.0.14: theatnafeature, the ITI-20 sender (the DICOM audit
message, the RFC 5424 syslog frame, the RFC 5425 TLS connection, the
spool and the forwarder), andAuditEvent::messagefor the ITI-55
Initiating Gateway table.AuditRecorder::recordis async, awaited before
the exchange's answer is returned. -
The consent pre-filter applies to the read of an EHR by subject as it does
to a federated query (#399; N27a, §13.2.1). A member it denies is never
resolved and never sent a request. When it denies every member that might
hold the subject and no other member holds one, the answer is
403 consent-deniednaming the denied endpoints, never the404of a
subject with no EHR. A pre-filter that cannot answer leaves every member to
its own consent check, as on a query. -
A consent pre-filter outage is visible (#400). The answer to a query asked
while the pre-filter could not answer carriesmeta.federation.consent.error,
beside thelocalization.errorof §14.1, withcompleteand the status
unchanged.GET /health/dependenciesreports the pre-filter asconsent
under the members' rule (a decision or an answer below500isup, a
5xxisfailing, no answer isdown), absent when none is configured.
Each call is counted inferrofed_consent_prefilter_requests_totalby
outcome(denied,no-signal,unavailable). The carriers are
FerroFED's own design; no specification names one. -
federation.node_selection = "localized": an undirected patient query gets
its node set from a localizer (N4, N10, §14.1). A member the localizer does
not name is reportednot-localizedand never asked. A localizer that fails
or stays silent pastfederation.localization.timeout_msfails closed: no
member is asked, every member isnot-localizedwith the localizer's error,
andmeta.federation.localization.errorcarries it too.
federation.localization.on_failure = "ask-all"widens to every member
instead, andOPTIONS {base}/declares the policy, the binding as
localization.modeand the budget astimeout.localization_ms. A directed
query is never localized (§8). The development cross-reference serves as
the localizer underprofile = "development"; the IHE XCPD binding follows
(#85). -
Consent stays with the node, and a node's consent refusal can be reported
as one (#83; §13.2, §13.2.1, §11.1, §11.3, N26, N27, N27a, N40, CP-36).
ITS-REST defines no consent signal, so a node's403isconsent-denied
only when its ITS-RESTErrorcarries acodethe registry lists for that
endpoint in the newconsent_refusal_codeskey (in the FHIR form, one
https://ferrofed.eu/fhir/StructureDefinition/consent-refusal-code
extension per code). The list is empty by default, so every refusal stays
node-erroruntil you name your nodes' codes; the key is FerroFED's own
design. A consent-denied node contributes no rows, carries the latency of
the request it refused, clearsmeta.federation.complete, and fails the
query in neither completeness mode. The optional Step-1 consent
pre-filter runs after localization and before resolution: each member it
denies is reportedconsent-deniedwith nolatency_ms, is never
resolved or contacted, and loses anyehr_idthe session cached for it. A
member it does not deny is asked, and its node decides. When the pre-filter
cannot answer, every candidate is asked (pass-to-node), because the node
checks consent in any case.OPTIONS {base}/declares a configured
pre-filter underfederation.consent. For development,
[[dev.consent_denied]]rows beside the cross-reference are a static
pre-filter, accepted only underprofile = "development"and declared as
development-static. -
A "How it works" part of the book, right after the introduction, explains
the gateway in Mermaid diagrams (#395; no specification governs the
documentation: our own design). Its pages cover a federated query end to
end with each member's status and the answer's status (§4, §11, N37), where
the patient identifier stops (§5.4, N33), follow-ups and writes (§12, §12a,
N41, N42), definitions and stored queries (§12.6, §12.7, N43, N44), trust
and keys (§13.1, N25), and the quickstart and production layouts. Each
diagram cites the sections it shows, and a planned part is drawn dashed
with its issue and milestone. The pages of the other parts link to the
diagram that belongs to them. The diagrams follow the book's theme when
you choose "Auto" too:website/book/js/mermaid-theme-sync.jsredraws them
when the theme crosses between light and dark, which the vendored
mdbook-mermaid script does only on a click of a named theme. -
The gateway authenticates to a node as itself with OAuth 2.0 client
credentials and a signed JWT client assertion, the default onward mechanism
of §13.1 (N25, CP-17 onward half, #81). A[credentials."<endpoint id>".oauth2]section names the node's token endpoint, theclient_idand
the scope, written in the SMART on openEHR grammar and held to thesystem
compartment, with optionalresourceandaudience. Each assertion is
ES384, names the client asissandsuband the token endpoint asaud,
lives at most 300 seconds and carries a freshjti(RFC 7523 §3). A token
is cached until 30 seconds before it expires, with one token request per
endpoint at a time, and a node's401drops it. The admission check
authenticates the same way. -
[signing]holds the gateway's ES384 signing key, read from a file, and
during a rotation the previous key, published beside it for a configurable
overlap window of at least the assertion lifetime plus the nodes' JWK Set
cache time. The gateway serves its public keys as a JWK Set (RFC 7517) at
GET {base}/.well-known/jwks.json, each with its RFC 7638 thumbprint as
kid, and declares the configured location asfederation.auth.jwks_uriin
OPTIONS {base}/(§13.1, N30). -
XCPD localization (#85, Annex A.3):
[xcpd]makes the gateway an IHE XCPD
Initiating Gateway (ITI-55, ITI TF-2 Revision 20.1). An undirected patient
query asks every configured Responding Gateway by the patient identifier
alone, and only the members whose communities a gateway names are asked.
One gateway that faults, answers an error, asks for demographics or stays
silent fails the discovery closed. Gateway URLs arehttpsoutside
profile = "development"; a client certificate, extra trust roots and a
SAML XUA assertion your identity provider signed are configured with
_filekeys, and the assertion is sent unchanged.OPTIONS {base}/
declareslocalization.mode = "xcpd", and[xcpd]takes effect on a
reload. -
ihe-iti0.0.11: thexcpdfeature, the ITI-55 Initiating Gateway client.
It is the only feature that compilesuuidandjiff, and it adds no
crate to the graph beyond them. -
The read of an EHR by subject is localized (#409; N4, §5.2, §14.1): under
node_selection = "localized"the subject is resolved only at the members
the localizer names, and no other member learns of the request. A
localizer that fails closed answers424 localization-unavailablewith no
member asked; a targeted read is never localized (§8). -
The localizer on
GET /health/dependenciesaslocalizer, under the
members' rule, and its calls inferrofed_localizer_requests_totalby
outcome(#410). -
The ITI-55 Initiating Gateway audit message (#410, ITI TF-2 §3.55.5.1.1):
ihe-iti0.0.12 hands every exchange's message to anAuditRecorder, and
[xcpd] audit = "log"writes it at theferrofed::auditlog target
without the query parameters, which name the patient. A message the
recorder cannot accept fails the discovery closed under every
on_failurepolicy,ask-allincluded, and shows the localizer
failing. -
The PIXm resolver as the localizer (#408; §14.2, N4, §14.1): under
node_selection = "localized"with[pixm]and no[xcpd], the members
whose domain holds an identifier for the patient at the PIX Manager are
the candidates, and the resolution reuses the same ITI-83 answer, so each
query asks each Manager once. A Manager that fails or stays silent fails
the localization closed.OPTIONS {base}/declares
localization.mode = "pixm". -
Client authentication at the gateway (§13.1, N25, CP-17 inbound half,
#80). A request to the ITS-REST surface andOPTIONS {base}/carries an
RFC 9068 access token from an issuer on the[[auth.issuer]]trust list,
verified against the issuer's JWK Set (fetched fromjwks_uri, read from
jwks_file, or given inline asjwks) or by RFC 7662 introspection. The
key set is cached, refetched once for a key it does not hold and at most
once perauth.key_set_refetch_s. Scopes are read in the SMART on openEHR
grammar withopenehr-sdt, and one table maps every ITS-REST operation to
what it requires. A purpose of use, in the IHE IUA extension or RFC 9396
authorization_details, is required unless
auth.purpose_of_use.required = false(§13.4). The verified caller rides
in the request's extensions. An explicit edge mode verifies a proxy's
signed assertion in a configured header and logs the identity it asserted.
New codes:unauthenticated(401),scope-insufficient,
purpose-of-use-requiredandoperation-refused(403), and
authentication-unavailable(503). The book has a new page, Client
authentication. -
scripts/quickstart/token.shmints the token the quickstart gateway
accepts, from a development issuer whose key pair it generates with
opensslon its first run; the quickstart and the release example
configuration carry an[auth]section. -
The testkit's test issuer: key pairs generated per run, its JWK Set served
from a mock server, and tokens minted with chosen claims. -
The caller's identity on every request to a node (#82; §13.1, N24, N25,
§12.4, CP-16). The federated query, every routed read and write, a
definition request, the template and stored-query fan-outs, the ask-all
probe and the read of an EHR by subject each carry
openEHR-federation-client: an ES384 JWS, typed
openehr-federation-client+jwt, signed for that one node with the
gateway's[signing]key, the key its JWK Set publishes. Itsaudis the
node's endpoint id,expis 60 seconds afteriat,jtiis fresh, and
issis theclient_idof the endpoint's OAuth 2.0 grant or else the
federation id. It names the verified caller (sub,iss_upstream), how
the gateway verified it (verified_by:signature,introspectionor
edge), the caller's organisation (subject_organization_id), each
purpose of use and the scopes as granted, so a node can apply them (N26).
The admission check and the admin listener's redistribution name the
gateway itself. The book's
Client authentication
page gives the claims and the steps a node verifies the token with. The
header and its claims are FerroFED's own; §13.1 leaves end-user conveyance
open. -
scripts/quickstart/signing-key.shwrites the compose quickstart's
development signing key before the firstdocker compose up, and the
quickstart and the release example configuration carry[signing]. -
The book's
§13.4 deployment decisions
page (#84; §13.4, N25, CP-39): FerroFED's own answer to each of the five
obligations §13.4 puts on a deployment (the identity verified at each
boundary, who authenticates the end user, how purpose of use travels, what
the tokens are bound to, and which risks are carried technically and which
by agreement), the configuration that changes each answer, and a template
an operator fills in for what only a deployment can answer. A test holds
the page to one section per obligation.
Changed
- Every outbound client of
ferrofed-identity(PIXm, PDQm, PMIR, mCSD,
XCPD, NVI, Mitz and the FHIR Feed audit repository) is built through one
client build and one TLS type, and the server maps a credentials section
to a credential once, refusing an OAuth 2.0, Nuts or FAPI 2.0 grant
wherever one is not allowed; the PMIR mapping no longer reads such a grant
as no credential (#507). - The container image names FerroHEALTH, the organisation that distributes
it, as its OCIvendor, and FerroHEALTH as itsauthors, in the image
labels and in the release index annotations (#490). - A production gateway with
[pixm],[registry.mcsd]or[pmir]set
needs[audit] destination:config check,serveand a reload refuse
the configuration without it, namingaudit.destination, and refuse
"off"outside development. Setdestination = "log"to keep a
deployment's behaviour while it adds an Audit Record Repository (#486). - The quickstart's node database roles have new development passwords (#452):
each role's password is its name followed by_example
(ferroehr_a_example), set bydocker/postgres/20-ferrofed-node-databases.sh
andcompose.yamltogether. A quickstart volume created before this
change keeps the old passwords, so the nodes are refused by their
databases: rundocker compose down -vonce before the next
docker compose up --wait, which removes the quickstart's data and its
synthetic patients, then runscripts/quickstart/seed.shagain. - The
openehr-*family moves from 0.0.81 to 0.0.82 (FerroEHR #3557,
#3558). An onward OAuth 2.0 grant now requests its configuredscopein
the canonical form of the SMART on openEHR grammar, asopenehr-sdt
prints it (#456): the permissions inc,r,u,d,sorder and one
space between scopes, sosystem/aql-*.sris requested as
system/aql-*.rs. A token exchange asks for the caller's covering scopes
in the same form. An authorization server that compares scope strings
sees the canonical form, not the text in the configuration or the
caller's token. - A
DPoP-bound onward token is sent by theopenehr-itsclient itself
(#448): the client writes theDPoPscheme and asks the endpoint's key
for each request's proof, and answers a node'suse_dpop_noncechallenge
with one re-send. The token request proves itself and answers a token
endpoint'suse_dpop_noncethe same way, with a newly signed client
assertion, and under token exchange a new actor token, so nojti
repeats (RFC 7523 §3). The token endpoint's nonce and the node's are kept
apart even on one origin (RFC 9449 §9). - A node that demanded a
DPoPnonce and was not sent the request again
is counted as asked (#470). A deadline that passes before the re-send is
the node'stime-out, "no answer before the deadline, which passed
before the request could be sent again with the DPoP nonce the node
demanded", and a re-send no proof could be made for isnode-error, "the
node demanded a DPoP nonce, and no proof could be made to send the
request again". The health and metrics surfaces read both as a node that
gave no answer, never as a request never sent. A request no proof could
be made for at all isnode-error, "no DPoP proof could be made for the
request, so nothing was sent". - The Kubernetes example bounds the gateway's ephemeral storage (#452): the
container requests128Miand is limited to1Gi, and the audit-spool
emptyDirhas asizeLimitof512Mi, which holds the default spool
bounds (spool_max_bytes64 MiB,spool_max_events100000). The kubelet
evicts a pod past either limit, so raise both with the spool bounds. - The image ships
/var/lib/ferrofed/audit-spool, owned by the gateway
user65532:65532with mode0700(#442), so the ITI-20 audit spool
needs no step on the host. The releasecompose.yamlmounts the named
volumeaudit-spoolat/var/lib/ferrofed, and the Kubernetes example an
emptyDirthere, writable throughfsGroup65532. Set
xcpd.audit_repository.spool_dir = "/var/lib/ferrofed/audit-spool"; the
spool holds audit records that name patients, so keep the volume on an
encrypted disk. - A
patient/scope grant admits nothing at the gateway, now as a decision
(#413): anehrIdnames no namespace and anehr_idmeans nothing outside
its CDR (§12.5), so a bare match could admit another patient's EHR at
another node. The authentication, How it works and deployment-decision
pages say so, and an issuer-bound opt-in is planned (#443). [signing]is required whenever a registry is configured, by
registry.documentor by[registry.mcsd]: a federating gateway without a
signing key refuses to start, andconfig checkrefuses
its configuration with exit code 78, naming the key.- The ADMIN API under
{base}/v1/admin/answers403(operation-refused)
to every caller, where it answered501. - A gateway that federates and trusts no issuer refuses to start, and
config checkrefuses its configuration, with exit code 78. [xcpd]requiresaudit(#410):"log", or"off", which only
profile = "development"accepts andOPTIONS {base}/declares as
localization.audit. A configuration without it refuses to boot.ihe-iti0.0.12:XcpdError::Faultcarries the HTTP status it came with,
XcpdError::status()names the status the responding gateway answered
with, an unreadable answer with a status other than200isRejected,
andXcpdError::Auditreports an audit message the recorder refused.- An onward credential that cannot be obtained fails that node as
node-errorwith nothing sent to it, on the federated query, the
definition fan-out and a routed request (424 node-error), where it was a
gateway error before (§13.1, §11.1). - The localizer error a member and
meta.federation.localization.error
carry names the status the localization service answered once, with the
binding's own reason, for PIXm and XCPD alike (#420). The log keeps the
whole cause chain. - The boot refusals that ask for a registry (
[dev],[pixm]and
federation.demographic_endpointwithout one,federation.node_selection
andfederation.idmissing beside one) name both of its sources,
registry.documentand[registry.mcsd](#425). So does the refusal of
the admission check without a registry, and a registry in FHIR form that
lists an endpoint it does not hold says the reference names no resource,
where it said it named noOrganization(#434). - The book draws the v0.0.8 work on
mainas built (#406). The How it works
pages show the gate, the onward OAuth 2.0 token with its ES384 assertion
and cache, the signed caller on every node request, the XCPD and PIXm
localizers, the mCSD directory and the resolution bindings per verified
caller; only the ATNA audit repository (#418), PMIR (#147) and the Dutch
Generic Functions (#87, #88) stay dashed as planned. The claims page lists
the merged v0.0.8 items as onmain, in the next release, and v0.0.7 as
released. The README,llms.txtand the introduction say client
authentication and onward authentication are built.
Fixed
- The Mitz consent pre-filter's budget,
nl_gf.mitz.timeout_ms, is now a
part of the overall budget, as the localizer's and the PDQm step's are:
config check,serveand a reload refuse a configuration whose
pre-filter, PDQm and localizer budgets together reach
federation.overall_timeout_ms, naming the keys, andOPTIONS {base}/
declares the budget astimeout.consent_ms(§11.5, #538). Before, a long
Mitz timeout could take the time left to resolve and ask the members with
no refusal at startup. The PDQm check now also counts the localizer's
default budget undernode_selection = "localized"when
[federation.localization]is not written, where it counted none. - An XCPD discovery whose audit message is still being stored when the
localization budget runs out now fails closed under
on_failure = "ask-all"too, as every exchange the gateway could not
audit does (#529). The gateway stopped waiting at the very instant the
discovery's own bound fired, so it read the discovery as a localizer that
did not answer, and ask-all widened the query to every member. The
localizer and the[pdqm]step are now given a deadline a tenth of their
time short of the gateway's, so the audit failure reaches the gateway
first and leaves every membernot-localized. - An audit record still queued behind another spool write when its
exchange's time runs out is now written once that write ends, and
delivered once, in the order it was queued (#532). Before, the record of
a failed exchange was dropped before its write started, and nothing
logged it. A failed exchange is still audited, and ITI-20 has a stored
record sent when the sender is able (ITI TF-2 §3.20.4.1.1). A queued
record holds its place underspool_max_eventsandspool_max_bytes
as a stored one does, so a stalled disk holds no more records in memory
than the spool could hold on disk: a record past either bound is
refused at once as a full spool, its exchange fails as an audit
failure, and the refusal is logged with its count, never the record,
and counted in the newferrofed_audit_refused_total. - A slow or stalled disk under the audit spool no longer holds a patient
query past its budget (#512). Storing one record is bounded by the new
spool_write_timeout_ms(2000 by default) in[audit.repository]and
[xcpd.audit_repository], and the record of an ITI-83, ITI-78, ITI-119
or ITI-55 exchange also by the time left to that exchange. A record
not stored in time is an audit failure under the policy already in
place (424for the query,503for an ITI-93 message), and is not
counted in the spool's depth. Its write goes on: a record it stores is
delivered like any other, and a write that fails is logged, with the
sequence number and never the record. One write runs at a time, so a
stalled disk holds one thread, and the audit metrics and
GET /health/dependenciesread the spool's depth without waiting on the
disk. - The ITI-55 audit trail starts its delivery again when the runtime it was
started on has ended, so audit messages recorded after that are delivered
rather than left in the spool (#486). - A
[[pixm.manager]]URL that is nothttpsoutside
profile = "development"is refused while the configuration loads, as an
XCPD gateway URL already was, naming its key; before, onlyserve,
config checkand a reload caught it (#464). - The example Kubernetes manifests start a gateway again (#428): the
ConfigMap trusts an example issuer in[auth]and names the gateway's
signing key in[signing], read from theferrofed-secretsSecret the
Deployment now mounts at/run/secrets/ferrofed. CI runs
ferrofed config checkover the ConfigMap's configuration with synthetic
secrets (scripts/checks/kubernetes-example.sh), so the example cannot
drift from what the gateway accepts. - A refresh of the mCSD directory whose change the gateway refuses no longer
reports the directoryuponGET /health/dependencieswhile the gateway
serves the registry it last accepted (#481). The directory shows
degraded, withdirectory_faultnaming the class of the refusal
(registry-invalidorconfiguration-mismatch) and never a member or a
URL, until a later refresh is accepted. A directory that did not answer
still showsdown, and one whose answer is brokenfailing. A directory
that answers a refresh with a4xxshowsfailingwhere it showedup,
and a401or403namesdirectory_fault = "refused-credentials".