diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9365d0..f1587e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,12 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Optional `mimalloc` feature that uses mimalloc as the global allocator. Enabled by default in the Docker images to reduce memory growth in long-running deployments (#68)
+- Opt-in access-audit log (`telemetry.audit.enabled`, disabled by default): one JSON line per HTTP request on stdout
+ with the user and subject verified by an authenticating reverse proxy (`telemetry.audit.user-header` and
+ `telemetry.audit.subject-header`, default `X-Forwarded-Email` and `X-Forwarded-User`), source address, method,
+ path, DICOM coordinates, status and duration. With auditing enabled, the C-MOVE completion log line also carries the
+ Study Instance UID (#62)
+- With auditing enabled, line breaks inside a regular log message are escaped, so request data carried by a log message
+ cannot forge a line on the stdout stream that also carries the audit records (#62)
+- Trusted relays for the access-audit log (`telemetry.audit.trusted-relays`, `telemetry.audit.on-behalf-of-header`):
+ explicitly trusted callers can name the end user they act for, recorded as `on_behalf_of` next to the caller's own
+ identity (a recorded claim, never used for authorization); claims from other callers or malformed claims are
+ recorded as `on_behalf_of_rejected` without the claimed value (#62)
+- `request_id` in the access-audit record, taken from `X-Request-Id`, for correlation with proxy access logs (#62)
### Fixed
### Changed
- Updated `dicom-rs` dependency to 0.10.0
+- ANSI colors in the log output are disabled when stdout is not a terminal (e.g. in containers), so collected logs stay
+ machine-parseable. `NO_COLOR` is still honoured on terminals (#62)
## [0.3.1] - 2026-09-14
diff --git a/Cargo.lock b/Cargo.lock
index a0db6fc..73e9661 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1711,6 +1711,7 @@ dependencies = [
"axum-extra",
"axum-streams",
"bytes",
+ "chrono",
"config",
"dicom",
"dicom-json",
diff --git a/Cargo.toml b/Cargo.toml
index 635c746..6e5d0a9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,6 +32,8 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
# Logging
tracing = "0.1.41"
+# Timestamps for the audit log; already transitive via dicom-core.
+chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
# Convenient error handling
thiserror = "2.0.17"
@@ -66,6 +68,8 @@ aws-credential-types = { version = "1.2.13", optional = true }
testcontainers = "0.27.3"
dicom-test-files = "0.4.0"
dicom-web = "0.6.0"
+# `ServiceExt::oneshot` for middleware tests
+tower = { version = "0.5.2", features = ["util"] }
[lints.rust]
unsafe_code = "forbid"
diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md
index 547443f..af18a3f 100644
--- a/docs/topics/configuration.md
+++ b/docs/topics/configuration.md
@@ -81,6 +81,8 @@ aets:
telemetry:
sentry: https://sentry.local/dsn
level: INFO
+ audit:
+ enabled: false
```
@@ -98,8 +100,177 @@ telemetry:
TRACE
+
+ Structured access-audit logging, disabled by default.
+ See Access Audit Config.
+
+
+
+## Access Audit Config {id="access-audit-config"}
+
+%product% performs no authentication itself. When it runs behind an authenticating reverse proxy, it can write one
+access-audit record per HTTP request, naming the user the proxy verified and the DICOM resources that were accessed.
+Read the trust model before relying on the identity fields.
+All settings are optional; with enabled: false (the default) nothing changes.
+
+```yaml
+telemetry:
+ audit:
+ enabled: true
+ user-header: X-Forwarded-Email
+ subject-header: X-Forwarded-User
+ trusted-relays: []
+ on-behalf-of-header: X-On-Behalf-Of
+```
+
+
+
+ Enables the access-audit log (default false).
+ Every HTTP request then emits one self-contained JSON line on stdout.
+ Delivery is fail-open: records pass through a bounded buffer to a dedicated writer thread, so a slow log
+ consumer never blocks requests. When the buffer is full, records are dropped and counted; a warning with the
+ running count is logged for the first drop and then for every 100th.
+ On a graceful shutdown (server.http.graceful-shutdown, enabled by default) %product% waits up to
+ 5 seconds for buffered records to be written. Without a graceful shutdown, buffered records are lost.
+
+
+ The request header carrying the user verified by the proxy, recorded as user
+ (default X-Forwarded-Email).
+ This is also the identity that trusted-relays is matched against.
+
+
+ The request header carrying the subject identifier verified by the proxy, recorded as subject
+ (default X-Forwarded-User).
+
+
+ Identities, exactly as the proxy asserts them in user-header, that may name the end user they act
+ for (default: empty, nobody may).
+ Use this for services that call %product% with their own credentials on behalf of a signed-in user.
+ Matching is ASCII case-insensitive. Empty entries are rejected at startup.
+
+
+ The request header in which a trusted relay names the end user (default X-On-Behalf-Of).
+ It is only read when trusted-relays is not empty, and must differ from
+ user-header and subject-header.
+
+Header names are validated when the configuration is loaded; an invalid name, or a header that carries credentials
+(Authorization, Proxy-Authorization, Cookie,
+X-Forwarded-Access-Token), stops %product% at startup with an error naming the offending key. The credential
+check is a guard against an obvious misconfiguration, not an exhaustive list: never point an identity header at a header
+that carries a secret.
+
+### Audit Record
+
+```json
+{"audit":"http-access","ts":"2026-08-17T17:16:55Z","user":"jane.doe@example.org","subject":"3f2c9a4e","source":"192.0.2.10","method":"GET","path":"/aets/PACS/studies/1.2.3.4","aet":"PACS","study":"1.2.3.4","status":200,"duration_ms":4886,"request_id":"0f8c2b7e"}
+```
+
+| Field | Description |
+|-------------------------|-------------------------------------------------------------------------------------------|
+| `audit` | Always `http-access`. |
+| `ts` | Time the response head was produced (UTC, RFC 3339, second precision). |
+| `user`, `subject` | Values of `user-header` and `subject-header`: 1 to 320 bytes of UTF-8 without control characters. Omitted if absent, sent more than once or malformed (never truncated). |
+| `on_behalf_of` | The end user named by a trusted relay: a claim, not a verified identity (see below). |
+| `on_behalf_of_rejected` | Why an on-behalf-of header was ignored: `untrusted-caller` or `invalid`. |
+| `source` | Leftmost entry of `X-Forwarded-For`, at most 64 bytes. |
+| `method`, `path` | Request method, and path with query string (at most 8 KiB). |
+| `aet`, `study`, `series`, `instance` | DICOM coordinates from the request path, at most 256 bytes each. If any path parameter cannot be decoded, all four are omitted; `path` is still recorded. |
+| `status`, `duration_ms` | Status and elapsed time when the response head was produced, including `408` for timed-out requests. |
+| `user_agent` | `User-Agent` header, at most 512 bytes. |
+| `request_id` | `X-Request-Id` header, if sent once with 1 to 128 printable ASCII characters (no spaces). |
+
+Absent values are omitted. Values longer than their limit are cut at a character boundary and end in `…`; the limits
+include the marker.
+With auditing enabled, the log line of a completed C-MOVE also carries the study_uid, and line breaks inside
+a regular log message are escaped (\n, \r): audit records and the regular log share stdout, and a
+message that carries request data (a percent-decoded path segment can contain a newline) must never start a line of its
+own that looks like an audit record. With auditing disabled, the log output is unchanged. The guarantee is about
+\n and \r; a consumer that also splits on Unicode line separators (U+2028, U+2029, U+0085) is not
+covered.
+
+What the record does and does not show:
+
+- `source` and `request_id` are copied from the incoming request. Both are whatever the client sent, unless the
+ proxies in front of %product% overwrite them.
+- `status` and `duration_ms` are taken when the response head is produced. A streamed retrieve that fails after that
+ is still recorded with the status of its head.
+- A client that disconnects before the response head is produced, or a request whose handler panics, can leave no
+ record at all.
+- A record without `user` behind an authenticating proxy means the identity header did not arrive. Alert on such
+ records: besides misconfiguration, a client can make some proxies drop headers they inject by listing them as
+ hop-by-hop headers in `Connection`; the reverse proxy in Go's standard library, for example, removes every header
+ named there. This erases the identity; it cannot replace it with a chosen one.
+
+### Trust Model {id="audit-trust-model"}
+
+The identity fields are only as trustworthy as the deployment around %product%. All of the following must hold:
+
+
+
+
The authenticating proxy removes any user-header and subject-header a client
+ sends and sets them itself from the verified session. It must not remove the
+ on-behalf-of-header: a relay is itself a client of the proxy, and removing the header would switch
+ the feature off.
+
Every trusted relay sets the on-behalf-of-header itself, overwriting any existing value, to the
+ end user of its own verified session, and never forwards a copy it received from its own clients. Otherwise any
+ user of the relay can name someone else.
+
%product% is reachable only through the proxy, for example by binding server.http.interface to
+ 127.0.0.1 next to a sidecar proxy, or with a firewall or network policy. Anyone who can reach the
+ port directly can send any header.
+
+
+
+A relay is trusted because of the identity the proxy verified for it, never because of a header it sends itself.
+An on-behalf-of claim is recorded as on_behalf_of only if the request's user is listed in
+trusted-relays, the header occurs exactly once, and its value is 1 to 320 bytes of UTF-8 without
+whitespace or control characters. Otherwise the record carries on_behalf_of_rejected
+(untrusted-caller or invalid) and the claimed value is not logged.
+
+on_behalf_of is an unverifiable claim made by an authenticated relay, recorded next to the relay's own
+identity in user. %product% cannot check it, and it never grants or restricts access.
+
+### Example: oauth2-proxy
+
+In oauth2-proxy v7.15.3 (pkg/middleware/headers.go, pkg/apis/options/legacy_options.go),
+pass_user_headers (enabled by default) removes client-supplied X-Forwarded-User,
+X-Forwarded-Email, X-Forwarded-Groups, X-Forwarded-Preferred-Username and
+X-Forwarded-Access-Token from the request before setting them from the session, which makes the default
+user-header and subject-header suitable. The X-Auth-Request-* headers are response
+headers only: they are neither set on nor removed from the upstream request, so they must not be used as identity
+headers. Verify that the proxy version you run behaves the same.
+
+An oauth2-proxy configuration (excerpt) in front of %product%, accepting both interactive users and services that
+present their own bearer token:
+
+```toml
+provider = "oidc"
+oidc_issuer_url = "https://idp.example.org"
+upstreams = ["http://127.0.0.1:8080/"]
+email_domains = ["*"]
+# Default: sets X-Forwarded-User/-Email from the session, replacing client copies
+pass_user_headers = true
+# Lets services call with a bearer token issued by the same provider
+skip_jwt_bearer_tokens = true
+```
+
+For a service, the proxy fills the identity headers from the claims of its token, so with the default
+user-header the relay's token needs an e-mail claim.
+With the matching %product% configuration, requests from viewer@example.org may name the end user in
+X-On-Behalf-Of:
+
+```yaml
+server:
+ http:
+ interface: 127.0.0.1
+telemetry:
+ audit:
+ enabled: true
+ trusted-relays:
+ - viewer@example.org
+```
+
## Global Server Config
```yaml
diff --git a/src/audit.rs b/src/audit.rs
new file mode 100644
index 0000000..72156df
--- /dev/null
+++ b/src/audit.rs
@@ -0,0 +1,1238 @@
+//! Structured access-audit logging.
+//!
+//! When enabled (`telemetry.audit.enabled: true`), every HTTP request emits
+//! one self-contained JSON line on stdout describing WHO accessed WHAT:
+//!
+//! ```json
+//! {"audit":"http-access","ts":"2026-08-17T17:16:55Z","user":"jane.doe@example.org",
+//! "subject":"3f2c9a4e-…","source":"192.0.2.10","method":"GET",
+//! "path":"/aets/PACS/studies/1.2.3.4","aet":"PACS",
+//! "study":"1.2.3.4","status":200,"duration_ms":4886}
+//! ```
+//!
+//! Identity is read from the request headers named by
+//! `telemetry.audit.user-header` (default `X-Forwarded-Email`) and
+//! `telemetry.audit.subject-header` (default `X-Forwarded-User`).
+//! DICOM-RST itself performs no authentication (see #15/#42): these fields
+//! are TRUSTWORTHY ONLY when an authenticating proxy replaces any client
+//! copies of these headers with values from its verified session and is the
+//! only way to reach DICOM-RST. Whether a given proxy does so, and the rest
+//! of the trust model, is documented in the "Access Audit Config" section
+//! of `docs/topics/configuration.md`. A header that occurs more than once is
+//! ambiguous and treated as absent, as is a value that is not 1..=320 bytes
+//! of UTF-8 without control characters (never truncated: a shortened
+//! identity could equal someone else's). The record is emitted regardless —
+//! an absent identity is itself audit-relevant.
+//!
+//! A caller that acts for someone else (e.g. a backend service fetching
+//! images for a signed-in user) can name that end user in the header set by
+//! `telemetry.audit.on-behalf-of-header` (default `X-On-Behalf-Of`). The
+//! claim is recorded as `on_behalf_of` only if the caller's own verified
+//! identity (`user`) is listed in `telemetry.audit.trusted-relays` (ASCII
+//! case-insensitive), the header occurs exactly once, and its value is
+//! 1..=320 bytes of UTF-8 without whitespace or control characters.
+//! Otherwise `on_behalf_of_rejected` says why (`"untrusted-caller"` or
+//! `"invalid"`) and the claimed value is NOT recorded. With no trusted
+//! relays configured (the default) the header is not read at all and
+//! neither field ever appears. `on_behalf_of` is an unverifiable claim by an
+//! authenticated relay, recorded beside the relay's own identity; it is
+//! never used for authorization.
+//!
+//! `source` is the leftmost `X-Forwarded-For` entry and `request_id` the
+//! incoming `X-Request-Id` (1..=128 printable ASCII characters without
+//! space, sent once): both are client-asserted unless the proxy chain
+//! overwrites them. `request_id` lets a record be correlated with the
+//! access log of the proxy or ingress that set it. `path` (8 KiB),
+//! `user_agent` (512 bytes), `source` (64 bytes) and each DICOM coordinate
+//! (256 bytes) are capped; a cut value ends in `…` and, marker included,
+//! stays within its cap. If a path parameter cannot be decoded, the record carries
+//! no DICOM coordinates at all; `path` is still recorded.
+//!
+//! `status` and `duration_ms` are taken when the response head is produced,
+//! so a streamed retrieve that fails mid-body is recorded with the status of
+//! its head. A client that disconnects before the head, or a handler panic,
+//! can leave no record.
+//!
+//! Delivery is FAIL-OPEN by design: records flow through a bounded channel
+//! to a dedicated writer thread (not a Tokio task, so a stalled stdout never
+//! ties up a runtime worker); when the buffer is full the record is dropped
+//! and counted, and a warning with the running count is logged for the first
+//! drop and every 100th — a slow disk or collector never blocks request
+//! handling. Deployments with stricter requirements should alert on the drop
+//! warnings. After a graceful shutdown, [`AuditWriter::finish`] waits a
+//! bounded time for buffered records to be written; without a graceful
+//! shutdown they are lost.
+
+use std::io::Write;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::thread::JoinHandle;
+use std::time::{Duration, Instant};
+
+use axum::extract::{RawPathParams, Request, State};
+use axum::http::{HeaderMap, HeaderName, HeaderValue};
+use axum::middleware::Next;
+use axum::response::Response;
+use axum::RequestExt;
+use chrono::{SecondsFormat, Utc};
+use serde::Serialize;
+use tokio::sync::mpsc;
+use tracing::warn;
+
+use crate::config::AuditConfig;
+
+/// One audit record per HTTP request.
+#[derive(Debug, Serialize)]
+pub struct AuditRecord {
+ /// Discriminator for log pipelines; always `"http-access"` for now.
+ pub audit: &'static str,
+ /// Wall-clock request completion time (UTC, RFC 3339, second precision).
+ pub ts: String,
+ /// Proxy-verified user from `telemetry.audit.user-header`, if present.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user: Option,
+ /// Proxy-verified subject from `telemetry.audit.subject-header`, if present.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub subject: Option,
+ /// End user named by a trusted relay (see the module docs).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub on_behalf_of: Option,
+ /// Why an on-behalf-of header was ignored. The ignored value itself is
+ /// never recorded: it came from a caller that may not make the claim.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub on_behalf_of_rejected: Option,
+ /// First `X-Forwarded-For` entry, if present (at most 64 bytes, marker
+ /// included).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ pub method: String,
+ /// Full request path and query (at most 8 KiB, marker included). QIDO
+ /// match parameters are part of "which data was accessed" and are
+ /// deliberately included.
+ pub path: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub aet: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub study: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub series: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub instance: Option,
+ pub status: u16,
+ pub duration_ms: u128,
+ /// `User-Agent`, if present (at most 512 bytes, marker included).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_agent: Option,
+ /// `X-Request-Id`, if sent once as 1..=128 printable ASCII characters
+ /// without space.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub request_id: Option,
+}
+
+/// Why an on-behalf-of header was not honoured.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum OnBehalfOfRejection {
+ /// The caller is unidentified or not a configured trusted relay.
+ UntrustedCaller,
+ /// A trusted relay sent the header more than once or a malformed value.
+ Invalid,
+}
+
+/// Outcome of evaluating the on-behalf-of header for one request.
+#[derive(Debug)]
+enum Delegation {
+ /// Nothing to decide: no trusted relays configured, or no header sent.
+ NotClaimed,
+ Honoured(String),
+ Rejected(OnBehalfOfRejection),
+}
+
+impl Delegation {
+ fn into_fields(self) -> (Option, Option) {
+ match self {
+ Self::NotClaimed => (None, None),
+ Self::Honoured(end_user) => (Some(end_user), None),
+ Self::Rejected(reason) => (None, Some(reason)),
+ }
+ }
+}
+
+/// Longest accepted identity: room for the longest e-mail address
+/// (64-octet local part, `@`, 255-octet domain).
+const MAX_IDENTITY_LEN: usize = 320;
+
+const X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
+const MAX_REQUEST_ID_LEN: usize = 128;
+
+// Caps on values copied from the request as-is, so that one oversized
+// request cannot produce an oversized audit line. Longer values are cut at
+// a character boundary and end in `TRUNCATED`, the marker included in the
+// cap.
+const MAX_PATH_LEN: usize = 8 * 1024;
+const MAX_USER_AGENT_LEN: usize = 512;
+const MAX_SOURCE_LEN: usize = 64;
+/// Per DICOM coordinate (`aet`, `study`, `series`, `instance`): far above
+/// any valid AE title (16) or UID (64), so only garbage is ever cut.
+const MAX_COORDINATE_LEN: usize = 256;
+const TRUNCATED: &str = "…";
+
+/// The human-readable log's writer while auditing is enabled: stdout, with
+/// every line break inside one formatted log event escaped (`\n`, `\r`).
+///
+/// Audit records and the regular log share stdout, and a log message may
+/// carry request data (a percent-decoded path segment can hold a newline).
+/// Unescaped, such a message could start a line of its own that looks like
+/// an audit record. tracing-subscriber formats each event into a buffer and
+/// hands it to the writer with one `write_all` (`fmt_layer.rs`, 0.3.20), so
+/// only the event's final line break is kept; the test
+/// `a_newline_ending_a_format_argument_is_escaped_too` fails if a future
+/// version ever streams an event in pieces. The guarantee is about `\n` and
+/// `\r`: consumers that also split on Unicode line separators (U+2028,
+/// U+2029, U+0085) are not covered. Not used while auditing is disabled,
+/// which leaves the log output unchanged.
+pub struct LineSafeStdout;
+
+impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LineSafeStdout {
+ type Writer = LineSafe;
+
+ fn make_writer(&'a self) -> Self::Writer {
+ LineSafe(std::io::stdout())
+ }
+}
+
+/// See [`LineSafeStdout`].
+pub struct LineSafe(pub W);
+
+impl Write for LineSafe {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result {
+ let (body, end): (&[u8], &[u8]) = match buf.split_last() {
+ Some((b'\n', body)) => (body, b"\n"),
+ _ => (buf, b""),
+ };
+ let mut escaped = Vec::with_capacity(buf.len() + 8);
+ for &byte in body {
+ match byte {
+ b'\n' => escaped.extend_from_slice(br"\n"),
+ b'\r' => escaped.extend_from_slice(br"\r"),
+ other => escaped.push(other),
+ }
+ }
+ escaped.extend_from_slice(end);
+ self.0.write_all(&escaped)?;
+ Ok(buf.len())
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.0.flush()
+ }
+}
+
+/// Cloneable handle to the audit writer. Only exists while auditing is
+/// enabled.
+#[derive(Clone)]
+pub struct AuditSink {
+ tx: mpsc::Sender,
+ config: Arc,
+}
+
+/// Records dropped because the buffer was full (fail-open pressure valve).
+static DROPPED: AtomicU64 = AtomicU64::new(0);
+
+const BUFFER: usize = 1024;
+
+/// Start the stdout writer thread and return a sink feeding it, or `None`
+/// when auditing is disabled. Without a sink the middleware must not be
+/// installed at all, so the request path is exactly that of a build without
+/// auditing.
+///
+/// # Errors
+/// Returns an error if the writer thread cannot be spawned.
+pub fn start(config: &AuditConfig) -> std::io::Result