Skip to content

0.0.8

Choose a tag to compare

@github-actions github-actions released this 04 Oct 21:18
· 400 commits to main since this release
Immutable release. Only release title and notes can be modified.
v0.0.8
1ee0b2f

Security

  • The trace export records nothing of a client's traceparent or
    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 own Authorization header.

  • The error a 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 with none or an HMAC, of another
    typ than at+jwt, from an untrusted issuer, for another audience, or
    outside its validity window is refused, and an issuer that cannot be asked
    fails closed with 503. A patient/ 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 check and every reload refuse a credential sent to a URL that
    is not https, naming the key of the URL and of the credential: a registry
    endpoint with a [credentials] section, an oauth2 token endpoint, a PIX
    Manager with credentials, and a metrics.otlp_endpoint carrying 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, a WARN log line and config check name 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 be https, with or without
    a credential configured. Under the development profile they are named in
    the banner, the log and config check like a cleartext credential (#402).

  • A reload whose file changes profile is refused with class profile, 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 an http XCPD gateway before (#402).

  • One module holds every transport rule: a URL a credential or a patient
    identifier is sent to must be https outside the development profile,
    and a key set or introspection endpoint the gateway verifies callers
    against must be https, or http to loopback, under every profile (#402).

  • Outside the development profile, the stored-query store's PostgreSQL
    connection string must set sslmode=require when it carries a password
    to a networked host: disable and prefer, the driver's default, which
    falls back to no TLS, are refused naming stored_queries.url by
    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 and config check (#416).

  • No request reaches a node without the caller's identity: one that reaches
    the dispatcher with no verified caller is answered 500 and nothing is
    sent. The conveyed token never carries a patient identifier (§5.4.1, N33):
    it holds no IUA person_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-client header 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] take client_identity (or
    client_identity_file) and trust_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 behind https with 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_jwt assertion whose aud is the issuer, binds every token
    with DPoP, and sends the configured RFC 9396 authorization_details,
    taking only a token response that states the details granted. Token
    exchange per verified caller works as for oauth2. 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 an oauth2 section makes every client
    assertion name the authorization server's issuer, set in issuer, as its
    aud in place of the token endpoint (RFC 7523 §3; FAPI 2.0 §5.3.2.1).
    The token endpoint stays the default (#497).

  • nl-generic-functions 0.0.10 adds the oauth-metadata feature: 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
    identifier search that asks for the master domain only) or, where the
    deployment declares transaction = "iti-119", by the ITI-119 $match
    with onlyCertainMatches. 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 member not-resolved and fails nothing (N6); several
    matches, two master identifiers or an uncertain match refuse the
    resolution and fail the query 424, and the gateway never picks one. A
    Supplier outage follows the localization failure policy (§14.1). The
    Supplier is reached over https outside development, with no redirect,
    a bounded answer and its own timeout, which OPTIONS {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 as demographics on GET /health/dependencies and is
    counted in ferrofed_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-iti 0.0.19:
    PdqmClient::match_patient posts a MatchInput to [base]/Patient/$match
    and reads the Match Output Bundle, every matched Patient with its score
    and match-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 the urn:oid:2.999 example 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 its did:web
    key, by the VP Token Grant Type of Nuts RFC021. The token is always bound
    to the section's dpop_key_file with 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 is nl-generic-functions feature nuts-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, for TREAT or COC? 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 clears complete while 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 as partial in
    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 as nl-gf-mitz. The client is the new mitz
    feature of nl-generic-functions 0.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, so scripts/vendor/mitz.sh pins 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
    AuditEvent and, under the new [audit] table, posted to an ATNA Audit
    Record Repository over the FHIR Feed of ITI-20 (destination = "repository") or written to the ferrofed::audit log 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
    fails 424, a directory read is refused, an ITI-93 message is answered
    503 unapplied. The repository shows on GET /health/dependencies as
    audit_feed, and the identity Registry's fault can be audit-failed.
    ihe-iti 0.0.18 adds the balp feature, 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 (mCSD Organization.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-functions adds
    lrza::ura_in, and ihe-iti reads 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 is not-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 is https outside development, the
    section is refused beside [xcpd] and under ask-all, and
    OPTIONS {base}/ declares localization.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: feature nvi asks 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 a searchset of
    localization records about that patient; feature lrza reads the URA of
    an NL-GF Organization. The source of the IG (fhir.nl.gf 0.3.0,
    EUPL-1.2) is vendored under docs/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 serves POST {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 from feed_token_file,
    and only when it holds to the PMIR profiles; otherwise it is answered 401,
    400 or 415 and changes nothing. An applied merge or delete drops the
    resolution bindings of the ehr_ids it carries in a member's ehr_id
    domain, and an update, or a change that names no member ehr_id, drops
    every binding. The log and the new ferrofed_identity_feed_messages_total
    counter carry the change kinds, counts and results, never an identifier.
    GET /health/dependencies reports the Registry as identity_registry, and
    the subscription is found by search before it is created, so a create
    answered late is adopted and never made twice; one in error or off, or
    one the Registry lost, is replaced; failed checks back off up to 32 times
    check_interval_s; a create answered with no usable Location stops
    further creates and shows as identity_registry_fault = "unmanageable";
    and the drain stops the loop before it deletes the subscription.
    The Registry URL and the callback URL are https outside the development
    profile. The ihe-iti crate gains the pmir feature: the ITI-94
    subscriber and the ITI-93 feed reader and response, held to the PMIR 1.6.0
    package, now vendored under docs/specs/ihe-pmir/; the testkit gains a
    harness Patient Identity Registry. Track 8 stays deferred (#147).

  • [auth.issuer.patient] honours one issuer's patient/ 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's ehr_ids; config check refuses a
    binding whose endpoint the registry lacks, or that has no registry or no
    cross-reference. A bound issuer's patient/ scope counts on an EHR's data
    alone. The gateway reads the token's ehrId claim (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, an ehr_id the pairs do not place, EHR creation and
    definitions are 403 (patient-confinement) with nothing sent, nothing
    probed and, for another patient's read by subject, no resolution binding
    or index entry recorded. A query whose FROM reads a class beside its
    one scoped EHR (under AND or OR, a second EHR, an EHR under
    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-federation 0.0.38 adds
    Analysis::within_one_ehr, which says whether every class a query reads is
    contained under that one EHR. A token whose resource scopes are all patient/
    is refused the DEMOGRAPHIC API the same way, bound or not and even for a
    listed demographic client. A token without the claim is 403
    (patient-context-missing), and a cross-reference that cannot resolve the
    patient is 424 (patient-context-unavailable). The bare ehrId is never
    compared across members (§12.5). Each node's openEHR-federation-client
    token carries the patient's ehr_id at that node as ehrId, and only the
    covering patient/ scopes in scope (N26); the outbound gate refuses,
    with nothing sent, a request whose token would tell its node another
    ehr_id than 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_ratio sets the share of client requests whose
    spans the trace export sends, from 0.0 to 1.0, 1.0 unless 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
    serve process'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 with grant = "token_exchange" and a required resource (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 node node-error with nothing
    sent. Exchanged tokens are cached per endpoint, caller token and scope
    (the caller token hashed), at most 1024, and dropped on a 401. 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 or openEHR-federation-client
    claim carries it.

  • DPoP-bound onward tokens per endpoint (RFC 9449, #439): an oauth2
    section's dpop_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 the DPoP scheme, 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_endpoint names an http:// collector; off by default.
    A federated query is one trace: the request, the resolution, the fan-out,
    the merge, and one node_request span 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-REST operationId, 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
    traceparent from its own span in that trace. A client's traceparent
    and tracestate are 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 each ehr_id resolved, and a
    routed request by the same caller is routed by them before the ehr_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_ms and number at most federation.binding_capacity
    (100000 by default), and OPTIONS {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 of registry.document.
    At start the gateway reads the Organizations and Endpoints 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 on hl7-fhir-rest (§15.2, N19),
    a missing managing organisation (N20) or a duplicate id or system_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 on GET /health/dependencies as directory.
    config check reads the directory too. Credentials for the directory
    travel over https only, 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-iti 0.0.13: the mcsd feature 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's Organization and Endpoint resources kept in step
    with ITI-91 (Replica). IHE mCSD 4.0.0 is vendored under docs/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 MSGID IHE+RFC-3881 over 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 created 0700 with 0600
    files, and a directory open to other users refuses to start;
    spool_dir is required outside development, and [xcpd] home_community
    is required with audit = "repository", since the audit message names
    the gateway's homeCommunityID (§3.55.5.1.1). Recording only writes to
    the spool. Delivery is bounded at every step (connect_timeout_ms for the
    connection, send_timeout_ms for the TLS handshake and each write and
    flush), and a failure drops the connection and retries with a jittered
    exponential backoff up to retry_max_ms. A spooled message that cannot be
    read or is no whole frame is moved to the spool's quarantine/, 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/dependencies reports audit_repository (degraded while
    retrying, while messages wait and while any sits in quarantine), and the
    metrics ferrofed_audit_spool_events, ferrofed_audit_spool_bytes,
    ferrofed_audit_quarantined, ferrofed_audit_delivered_total and
    ferrofed_audit_retries_total carry no label. Where the audit messages go
    takes a restart.

  • ihe-iti 0.0.14: the atna feature, the ITI-20 sender (the DICOM audit
    message, the RFC 5424 syslog frame, the RFC 5425 TLS connection, the
    spool and the forwarder), and AuditEvent::message for the ITI-55
    Initiating Gateway table. AuditRecorder::record is 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-denied naming the denied endpoints, never the 404 of 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 carries meta.federation.consent.error,
    beside the localization.error of §14.1, with complete and the status
    unchanged. GET /health/dependencies reports the pre-filter as consent
    under the members' rule (a decision or an answer below 500 is up, a
    5xx is failing, no answer is down), absent when none is configured.
    Each call is counted in ferrofed_consent_prefilter_requests_total by
    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 reported not-localized and never asked. A localizer that fails
    or stays silent past federation.localization.timeout_ms fails closed: no
    member is asked, every member is not-localized with the localizer's error,
    and meta.federation.localization.error carries it too.
    federation.localization.on_failure = "ask-all" widens to every member
    instead, and OPTIONS {base}/ declares the policy, the binding as
    localization.mode and the budget as timeout.localization_ms. A directed
    query is never localized (§8). The development cross-reference serves as
    the localizer under profile = "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's 403 is consent-denied
    only when its ITS-REST Error carries a code the registry lists for that
    endpoint in the new consent_refusal_codes key (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-error until 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, clears meta.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 reported consent-denied with no latency_ms, is never
    resolved or contacted, and loses any ehr_id the 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 under federation.consent. For development,
    [[dev.consent_denied]] rows beside the cross-reference are a static
    pre-filter, accepted only under profile = "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.js redraws 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, the client_id and
    the scope, written in the SMART on openEHR grammar and held to the system
    compartment, with optional resource and audience. Each assertion is
    ES384, names the client as iss and sub and the token endpoint as aud,
    lives at most 300 seconds and carries a fresh jti (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's 401 drops 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 as federation.auth.jwks_uri in
    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 are https outside
    profile = "development"; a client certificate, extra trust roots and a
    SAML XUA assertion your identity provider signed are configured with
    _file keys, and the assertion is sent unchanged. OPTIONS {base}/
    declares localization.mode = "xcpd", and [xcpd] takes effect on a
    reload.

  • ihe-iti 0.0.11: the xcpd feature, the ITI-55 Initiating Gateway client.
    It is the only feature that compiles uuid and jiff, 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 answers 424 localization-unavailable with no
    member asked; a targeted read is never localized (§8).

  • The localizer on GET /health/dependencies as localizer, under the
    members' rule, and its calls in ferrofed_localizer_requests_total by
    outcome (#410).

  • The ITI-55 Initiating Gateway audit message (#410, ITI TF-2 §3.55.5.1.1):
    ihe-iti 0.0.12 hands every exchange's message to an AuditRecorder, and
    [xcpd] audit = "log" writes it at the ferrofed::audit log target
    without the query parameters, which name the patient. A message the
    recorder cannot accept fails the discovery closed under every
    on_failure policy, ask-all included, 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 and OPTIONS {base}/ carries an
    RFC 9068 access token from an issuer on the [[auth.issuer]] trust list,
    verified against the issuer's JWK Set (fetched from jwks_uri, read from
    jwks_file, or given inline as jwks) or by RFC 7662 introspection. The
    key set is cached, refetched once for a key it does not hold and at most
    once per auth.key_set_refetch_s. Scopes are read in the SMART on openEHR
    grammar with openehr-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-required and operation-refused (403), and
    authentication-unavailable (503). The book has a new page, Client
    authentication.

  • scripts/quickstart/token.sh mints the token the quickstart gateway
    accepts, from a development issuer whose key pair it generates with
    openssl on 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. Its aud is the
    node's endpoint id, exp is 60 seconds after iat, jti is fresh, and
    iss is the client_id of 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, introspection or
    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.sh writes the compose quickstart's
    development signing key before the first docker 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 OCI vendor, and FerroHEALTH as its authors, 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, serve and a reload refuse
    the configuration without it, naming audit.destination, and refuse
    "off" outside development. Set destination = "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 by docker/postgres/20-ferrofed-node-databases.sh
    and compose.yaml together. A quickstart volume created before this
    change keeps the old passwords, so the nodes are refused by their
    databases: run docker compose down -v once before the next
    docker compose up --wait, which removes the quickstart's data and its
    synthetic patients, then run scripts/quickstart/seed.sh again.
  • The openehr-* family moves from 0.0.81 to 0.0.82 (FerroEHR #3557,
    #3558). An onward OAuth 2.0 grant now requests its configured scope in
    the canonical form of the SMART on openEHR grammar, as openehr-sdt
    prints it (#456): the permissions in c, r, u, d, s order and one
    space between scopes, so system/aql-*.sr is 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 the openehr-its client itself
    (#448): the client writes the DPoP scheme and asks the endpoint's key
    for each request's proof, and answers a node's use_dpop_nonce challenge
    with one re-send. The token request proves itself and answers a token
    endpoint's use_dpop_nonce the same way, with a newly signed client
    assertion, and under token exchange a new actor token, so no jti
    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 DPoP nonce and was not sent the request again
    is counted as asked (#470). A deadline that passes before the re-send is
    the node's time-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 is node-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 is node-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 requests 128Mi and is limited to 1Gi, and the audit-spool
    emptyDir has a sizeLimit of 512Mi, which holds the default spool
    bounds (spool_max_bytes 64 MiB, spool_max_events 100000). 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
    user 65532:65532 with mode 0700 (#442), so the ITI-20 audit spool
    needs no step on the host. The release compose.yaml mounts the named
    volume audit-spool at /var/lib/ferrofed, and the Kubernetes example an
    emptyDir there, writable through fsGroup 65532. 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): an ehrId names no namespace and an ehr_id means 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.document or by [registry.mcsd]: a federating gateway without a
    signing key refuses to start, and config check refuses
    its configuration with exit code 78, naming the key.
  • The ADMIN API under {base}/v1/admin/ answers 403 (operation-refused)
    to every caller, where it answered 501.
  • A gateway that federates and trusts no issuer refuses to start, and
    config check refuses its configuration, with exit code 78.
  • [xcpd] requires audit (#410): "log", or "off", which only
    profile = "development" accepts and OPTIONS {base}/ declares as
    localization.audit. A configuration without it refuses to boot.
  • ihe-iti 0.0.12: XcpdError::Fault carries the HTTP status it came with,
    XcpdError::status() names the status the responding gateway answered
    with, an unreadable answer with a status other than 200 is Rejected,
    and XcpdError::Audit reports an audit message the recorder refused.
  • An onward credential that cannot be obtained fails that node as
    node-error with 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_endpoint without one, federation.node_selection
    and federation.id missing beside one) name both of its sources,
    registry.document and [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 no Organization (#434).
  • The book draws the v0.0.8 work on main as 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 on main, in the next release, and v0.0.7 as
    released. The README, llms.txt and 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, serve and a reload refuse a configuration whose
    pre-filter, PDQm and localizer budgets together reach
    federation.overall_timeout_ms, naming the keys, and OPTIONS {base}/
    declares the budget as timeout.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 under node_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 member not-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 under spool_max_events and spool_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 new ferrofed_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 (424 for the query, 503 for 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/dependencies read 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 not https outside
    profile = "development" is refused while the configuration loads, as an
    XCPD gateway URL already was, naming its key; before, only serve,
    config check and 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 the ferrofed-secrets Secret the
    Deployment now mounts at /run/secrets/ferrofed. CI runs
    ferrofed config check over 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 directory up on GET /health/dependencies while the gateway
    serves the registry it last accepted (#481). The directory shows
    degraded, with directory_fault naming the class of the refusal
    (registry-invalid or configuration-mismatch) and never a member or a
    URL, until a later refresh is accepted. A directory that did not answer
    still shows down, and one whose answer is broken failing. A directory
    that answers a refresh with a 4xx shows failing where it showed up,
    and a 401 or 403 names directory_fault = "refused-credentials".