Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication & access

Who may call the server, what they may do once identified, and the surfaces that are gated rather than always-on: [auth], [authz], [admin], [tenancy], [smart], [management], and [signing]. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[auth]

Authentication: Basic credentials, OAuth2/OIDC bearer tokens, or both.

[auth]
enabled = true
verified_cache_ttl_seconds = 60

[[auth.basic.users]]
username = "clinician"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…$…"   # never a plaintext password
roles = ["USER"]

[auth.oidc]
issuer = "https://keycloak.example.com/realms/ferroehr"
audiences = ["ferroehr"]
algorithms = ["RS256"]
KeyTypeDefaultDescription
enabledbooltrueMaster switch. false = all requests pass unauthenticated (development only). With true and no mechanism configured the server refuses to start — see below.
verified_cache_ttl_secondsint60Verified Basic-credential cache TTL (0 disables). Argon2 verification costs real CPU per call by design, so a credential that has verified is remembered — as a digest of the presented header, never plaintext — and re-verified after the TTL. It bounds both the KDF cost of a busy client and how long a revoked credential keeps working.

Warning

auth.enabled = true with no mechanism is a boot error. Such a server could only refuse every request while advertising an authentication scheme it does not implement, which RFC 9110 §11.6.1 forbids: a 401 challenge must name a scheme applicable to the target resource. Configure [[auth.basic.users]], configure [auth.oidc], or set auth.enabled = false for a development server.

The Basic-auth user store

[[auth.basic.users]] is an array of tables and therefore file-only — the environment grammar cannot spell an array index.

KeyTypeDefaultDescription
usernamestringrequiredPrincipal name. A blank or missing one is a boot error.
password_hashsecretrequiredArgon2id PHC hash ($argon2id$v=19$…), never a plaintext password. Boot-validated against the OWASP floor — see below.
password_hash_filepathunsetRead the hash from a file instead, for a mounted secret. A hash is an offline cracking target, so prefer this wherever the configuration file itself is not treated as sensitive. The Argon2id floor is validated identically either way, because validation runs after the file is resolved. Exactly one of the pair is required.
roleslist of string["USER"]Roles granted, upper-cased on authentication. Use ["ADMIN"] for an administrative account.

Warning

Every password_hash must meet the OWASP Argon2id floor: m>=19456 (19 MiB), t>=2, p>=1, algorithm argon2id. Anything weaker — or a non-argon2id PHC string, or an unparsable one — is a boot error naming the user. This is checked at startup because the verifier takes its cost parameters from the stored hash, so a deliberately cheap hash would otherwise verify happily and silently weaken every password in the store. The floor is the OWASP Password Storage Cheat Sheet §Argon2id minimum.

[auth.oidc]: bearer validation

The table’s absence disables bearer authentication entirely. When present, the server validates tokens as a resource server; it never issues them.

KeyTypeDefaultDescription
issuerstringrequired when the table is presentExpected iss; also the OIDC discovery base. Must be an absolute https URL with no query and no fragment (RFC 8414 §2) — boot-validated.
audienceslist of stringrequired, non-emptyAccepted aud. An empty or all-blank list is a boot error.
algorithmslist of string["RS256"]Accepted signature algorithms. Boot-bound to the key source: HS* requires hmac_secret, RS*/ES*/PS* require public keys (a static JWKS or the discovered one). none is refused outright.
require_at_jwtboolfalseRefuse a token that does not carry typ: at+jwt. A token that does carry it is held to RFC 9068 §2.2 either way — iat, jti and client_id become mandatory for it.
clock_skew_leeway_secondsint60Leeway on the time-based claims (exp/nbf). Capped at 300; above that is a boot error.
allow_insecure_issuerboolfalseAccept a non-https issuer. Development and test only.
hmac_secret / hmac_secret_filesecret / pathunsetSymmetric HS* secret (development/test), minimum 32 bytes. At most one of the pair.
jwks_json / jwks_json_filestring / pathunsetStatic JWKS document. At most one of the pair.
connect_timeout_msint3000TCP connect timeout for the discovery + JWKS fetches.
request_timeout_msint5000Whole-request timeout for the discovery + JWKS fetches (connect, TLS, body read).
negative_cache_ttl_secondsint10How long a failed discovery/JWKS fetch is remembered (0 disables).

The boot rules, and what each one prevents:

  • audiences must name at least one audience. RFC 7519 §4.1.3 obliges a recipient that does not identify itself with a value in a present aud claim to reject the JWT, and RFC 9068 §4 step 4 makes the check unconditional for an access token. A resource server that declares no audience cannot reject a token minted for a different resource server, and cannot tell an OpenID Connect ID token (whose aud is a client id) from an access token (RFC 8725 §3.9, §3.12). Set it to whatever your identity provider puts in aud for this CDR.
  • issuer must be an https URL with no query or fragment. That is the RFC 8414 §2 definition of an issuer identifier, and §6.2 requires TLS for issuer metadata — over plain HTTP an attacker on the network can serve their own signing keys. A development issuer is opted in explicitly with allow_insecure_issuer = true; the no-query/no-fragment rules still apply, since those are structural.
  • clock_skew_leeway_seconds is capped at 300. RFC 7519 §4.1.4 allows “some small leeway, usually no more than a few minutes, to account for clock skew”, and RFC 9068 §4 step 6 repeats the bound. A large leeway silently extends the life of every token past its exp.
  • hmac_secret must be at least 32 bytes. RFC 8725 §3.5: a human-memorizable password must not be used directly as the key to a keyed-MAC algorithm such as HS256. A symmetric key is also shared with the authorization server — meaning this server could mint the very tokens it accepts — so the boot log warns that it is a development posture. Prefer discovery or jwks_json.
  • The algorithm set is bound to the key source. A key belongs to one algorithm family, and accepting an algorithm the configured key material cannot verify is the algorithm-confusion setup RFC 8725 §3.1 warns about — most famously an RS256 deployment that also accepts HS256, letting an attacker sign with the public key as if it were a shared secret.

The signing-key source is exactly one of: the symmetric secret, the static JWKS, or — when neither is set — the issuer’s OIDC discovery document. Configuring both hmac_secret and jwks_json (in either direct or *_file form) is a boot error, never resolved by silent precedence. A validated token must also carry a non-blank sub claim: the authenticated subject is stamped into the audit trail, so a token without one is refused with 401 rather than recorded under a placeholder identity.

The last three keys apply only when keys come from OIDC discovery. The timeouts stop an unresponsive identity provider from parking bearer requests until the operating system’s TCP timeout; the negative cache means a provider outage costs one discovery attempt per negative_cache_ttl_seconds rather than one per incoming request, so callers get fast 401s instead of slow ones. Keep the negative TTL short — it is also how long recovery takes to be noticed after the provider comes back.

[authz]

Role-based (RBAC) and attribute-based (ABAC) authorization. The full evaluation order and design rationale are in Security.

[authz.rbac]

KeyTypeDefaultDescription
enabledbooltrueThe coarse role gate (active when authentication is enabled).
admin_rolestringADMINRole required for admin-class operations. A blank value is a boot error.
user_rolestringUSERBaseline clinical role.
readonly_rolestringREADONLYRole marking a principal read-only: refused on every write operation (create/update/delete/upload), even alongside granting roles. Reads and AQL queries are still allowed.
role_claimslist of string["roles","groups","entitlements","realm_access.roles"]JWT claim paths mined for roles, in order. Dotted paths walk nested claims. Must be non-empty and contain no blank path. scope is not a role source — see Security.
ehr_access_defaultenum{open,restricted}openWhat an EHR carrying no ACCESS_CONTROL_SETTINGS admits. restricted is object-level default-deny: only admin_role reaches a setting-less EHR, so an operator can still author the settings that open it. See Security.

Note

The management surface is not configured under [authz.rbac]. [management.endpoints] owns it, one level per endpoint, with no global default beside it — an endpoint you do not name is off and is not mounted. Only the admin_only level consults authz.rbac.admin_role.

[authz.abac]

KeyTypeDefaultDescription
enabledboolfalseMaster ABAC switch.
engineenum{cedar,remote}cedarEmbedded Cedar, or a remote decision point.
organization_claimstringorganization_idJWT claim carrying the caller’s organization.
patient_claimstringpatient_idJWT claim carrying the patient id.
check_directoryboolfalseSubmit DIRECTORY (FOLDER) operations to the decision point. Engine-independent, so it works under Cedar as well as a remote PDP.
  • [authz.abac.cedar]policy_dir (path, required when engine = "cedar" and ABAC is on) and reload_secs (int, unset — an optional hot-reload interval).
  • [authz.abac.remote]server (string, required when engine = "remote", and it must end with /, because the policy name is appended), connect_timeout_ms (int, 2000), request_timeout_ms (int, 5000).
  • [authz.abac.policy.<kind>] — one entry per resource kind, with kindehr, ehr_status, composition, contribution, query, directory. Keys: name (string, the policy to evaluate) and parameters (list of enum{organization,patient,template}). A key that is not one of those six kinds is a boot error, and template is rejected on ehr and ehr_status — neither carries a template.

Warning

With engine = "remote", every resource kind the enforcement point consults needs a policy entry: ehr, ehr_status, composition, contribution, query — plus directory when check_directory = true. A missing one is a boot error. At runtime a kind with no policy can only be denied, since there is no policy to ask and permitting would be a silent hole, so the misconfiguration is caught at startup rather than turning into blanket 403s on live traffic. The Cedar engine reads its policies from authz.abac.cedar.policy_dir and needs no entries here.

[admin]

KeyTypeDefaultDescription
enabledboolfalseMount the ADMIN API (physical, irreversible delete). Off ⇒ every admin route answers 405 Method Not Allowed with an empty Allow header, never 403, and never touches the backend.

Physical deletion is irreversible, so the group stays off by default. With it on, /admin also joins the group list the OPTIONS System-Options manifest advertises, so the manifest never names a group that answers 404.

[tenancy]

Multi-tenancy. Off by default: the tenant middleware is not installed, the pool takes no per-acquire hook, and the tenant CRUD routes answer 404, so a single-tenant deployment is unchanged.

KeyTypeDefaultDescription
enabledboolfalseActivate the tenant middleware and row-level scoping.
claimstringtenantJWT-claim path carrying the tenant key (a tenant name or uuid). A dotted path walks nested claim objects.
headerstringunsetDevelopment-only request-header tenant override; when set and present on the request it wins over the JWT claim. Leave unset in production — a client-supplied header must not select a tenant.

[smart]

SMART App Launch. Off by default; when off the discovery document is not served and the scope gate is inert. See SMART App Launch.

[smart]:

KeyTypeDefaultDescription
enabledboolfalseServe the discovery document and activate the scope gate.
platform_base_urlstringunset ⇒ the REST rootThe path the discovery document hangs off, e.g. /gateway/v1.
public_base_urlstringrequired when enabledThe server’s externally reachable origin (e.g. https://cdr.example.com), from which the discovery document’s absolute services.*.baseUrl values are built.
ehr_id_claimstringehrIdToken claim carrying the launch context’s openEHR EHR id.
patient_claimstringpatientFallback launch-context claim when ehr_id_claim is absent.
require_smart_scopesboolfalseWhen true, the resource-scope gate is fail-closed across the composition, template and AQL families, and the openehr-permission-v1 capability is advertised. When false the gate is advisory — it enforces only when a token actually carries SMART resource scopes — and the capability is not claimed.
launch_base64_jsonboolfalseAdvertise the launch-base64-json capability. Experimental, and advisory: the base64-JSON launch object is consumed by the application, not the CDR.

[smart.episode]: enabled (bool, false) — advertises episode context and accepts the launch/episode scope and episodeId claim, but applies no episode-scoped filtering.

[smart.endpoints] carries the external authorization-server endpoints the discovery document publishes verbatim: issuer, jwks_uri, authorization_endpoint, token_endpoint, registration_endpoint, introspection_endpoint, revocation_endpoint, management_endpoint (all string, unset ⇒ omitted from the document); the advertised lists token_endpoint_auth_methods_supported, grant_types_supported, response_types_supported, code_challenge_methods_supported, scopes_supported and capabilities (all list of string, []capabilities appends operator-advertised HL7 base capabilities such as launch-ehr or sso-openid-connect to the derived openEHR set); and allow_insecure_endpoints (bool, false).

Everything in [smart.endpoints] is published at /.well-known/smart-configuration for third-party applications to act on, so smart.enabled = true boot-validates it rather than relaying whatever is configured:

RuleWhy
Deprecated grant types (implicit, password) rejected — whether or not SMART is enabledthe SMART App Launch specification’s deprecated-flows section
public_base_url, authorization_endpoint, token_endpoint requiredan enabled Platform without them publishes an unusable document
Every advertised endpoint an absolute https URLthe document tells apps where to send an authorization request and exchange a code, so a plaintext endpoint exposes the code and the access token (RFC 6749 §3.1.2.1, RFC 8414 §6.2). allow_insecure_endpoints = true opts out for development
issuer has no query and no fragmentRFC 8414 §2 — the same rule auth.oidc.issuer follows, because it is the same identity
response_types_supported non-emptyRFC 8414 §2 marks the field REQUIRED
token_endpoint_auth_methods_supported non-emptyan empty list advertises a server that authenticates no client
code_challenge_methods_supported includes S256SMART App Launch requires PKCE (RFC 7636); publishing a list without it tells every app the server cannot do PKCE, and plain alone is not sufficient
smart.endpoints.issuer equals auth.oidc.issuerone says where apps obtain tokens, the other which tokens this server accepts. A mismatch means every app gets a valid token and every request is refused
smart.enabled requires [auth.oidc]the CDR cannot validate the tokens it directs applications to obtain

Note

An empty advertised list is not silence — it claims the authorization server supports none of that thing, and a conforming application will believe it.

[management]

The ops-introspection surface: build info, Prometheus, metric views, the effective configuration, runtime log control, and the on-demand profiler. Off by default, and every endpoint off individually.

The health probes are not configured here: /health, /health/liveness and /health/readiness are always served on the main API port without authentication, whatever this section says (see Operations → Health probes).

[management]
enabled = false
base_path = "/management"

[management.endpoints]
info = "off"
metrics = "off"
prometheus = "off"
env = "off"
loggers = "off"
flamegraph = "off"

[management.profiling]
max_seconds = 30
max_frequency = 999
KeyTypeDefaultDescription
enabledboolfalseMount the management router.
base_pathstring/managementBase path for the management endpoints.
portintunset ⇒ share the main listenerServe management on its own listener and port. Must differ from the server.bind port, and it is plain HTTP — an internal surface.

[management.endpoints]info, metrics, prometheus, env, loggers and flamegraph, each enum{off,admin_only,private,public}, default off.

LevelMeaning
offNot mounted at all; the route answers 404.
admin_onlyRequires an authenticated principal carrying authz.rbac.admin_role (401 unauthenticated, 403 authenticated but not admin).
privateRequires any authenticated principal.
publicServed outside authentication.

env renders the effective configuration and flamegraph starts a profiler on request, so the boot log prints exactly which endpoints a configuration turned on and at which level.

[management.profiling] — limits for the on-demand CPU flamegraph behind endpoints.flamegraph (see Operations → Profiling):

KeyTypeDefaultDescription
max_secondsint30Longest sample window one request may ask for. A request asking for more is refused with 400, never clamped.
max_frequencyint999Highest sampling frequency (Hz) a request may ask for. Same refusal semantics.

Warning

probes_enabled and endpoints.health do not exist. Configuration is strict, so a file or environment variable still setting either one fails at boot with an unknown-key error — delete the key; the probes are always on.

[signing]

VERSION signing. On by default in digest mode, with read-time verification of the server’s own signatures strict by default.

KeyTypeDefaultDescription
enabledbooltrueServer-side signing of committed versions.
modeenum{digest,pgp}digestA SHA-256 integrity digest, or an OpenPGP (RFC 4880) detached signature.
key_pathpathunsetArmored secret key; required for pgp (a boot error otherwise).
key_passphrase / key_passphrase_filesecret / pathunsetKey passphrase. At most one of the pair.
retired_key_pathslist of path[]Armored public keys retired from signing and kept for verification, so versions signed before a key rotation keep verifying.
verify_on_readenum{off,warn,strict}unset ⇒ strict when signing is enabledRead-time recompute-and-compare policy for the server’s own signatures.

retired_key_paths exists because a stored VERSION.signature records no key identifier and is an immutable committed fact that cannot be re-issued — keeping the retired public key is the only way history stays verifiable across a rotation, and a public key can verify but never sign. Its environment form takes a comma-separated list (FERROEHR__SIGNING__RETIRED_KEY_PATHS=/keys/a.pub.asc,/keys/b.pub.asc).

Note

verify_on_read resolves to strict when signing is enabled. On every read the server recomputes the signature of a version it signed and, on a mismatch, returns a 500 integrity fault rather than silently serving a provably corrupt record. Set it explicitly to warn (log + meter version_signature_invalid_total, still serve) or off (never check) to opt out. Client-supplied signatures — an author’s own, or one carried by an imported version — are always stored verbatim and never re-verified, whatever this setting says, because the author may have signed a different agreed serialization.

Warning

pgp mode fails closed at boot if the key is missing or unusable — the server will not start. Verify the key and passphrase before switching modes.

On Kubernetes, digest mode needs nothing — it is the default. pgp mode needs the key as a file and its passphrase as a secret, both of which the chart mounts for you:

# values.yaml
config:
  signing:
    enabled: true
    mode: pgp
    key_path: /etc/ferroehr/signing-key.asc
  files:
    signing-key.asc: |
      -----BEGIN PGP PRIVATE KEY BLOCK-----
      …
secrets:
  signingKeyPassphrase: "…"

Every config.files key becomes /etc/ferroehr/<key>, mounted read-only from a chart Secret at mode 0440 — that volume holds the private key, so it is never world-readable inside the container. To go back to digest, set mode: digest and drop the key material; versions already signed keep their signatures and still verify.