diff --git a/Cargo.lock b/Cargo.lock
index ed472b8..68d0aba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1686,6 +1686,7 @@ dependencies = [
"axum-extra",
"axum-streams",
"bytes",
+ "chrono",
"config",
"dicom",
"dicom-json",
diff --git a/Cargo.toml b/Cargo.toml
index 6a45930..ff424da 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,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"
diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md
index 547443f..c23c467 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,6 +100,20 @@ telemetry:
TRACE
+
+ Structured access-audit logging (default false).
+ When enabled, every HTTP request emits one self-contained JSON line
+ on stdout: timestamp, caller identity (read from the
+ X-Auth-Request-User/X-Auth-Request-Email
+ headers an authenticating reverse proxy such as oauth2-proxy
+ injects), source IP, method, path, the DICOM coordinates
+ (aet, study, series,
+ instance), response status and duration. Intended for
+ healthcare access-audit requirements; delivery is fail-open
+ (bounded buffer — a slow log consumer never blocks requests, drops
+ are counted and logged). The identity headers are trustworthy only
+ when the proxy is the sole ingress to DICOM-RST.
+
## Global Server Config
diff --git a/src/audit.rs b/src/audit.rs
new file mode 100644
index 0000000..351c74b
--- /dev/null
+++ b/src/audit.rs
@@ -0,0 +1,230 @@
+//! 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":"alex@example.com",
+//! "subject":"5939ae08-…","source":"10.244.9.49","method":"GET",
+//! "path":"/app/dicom-rst/aets/GEPACS/studies/1.2.840…","aet":"GEPACS",
+//! "study":"1.2.840…","status":200,"duration_ms":4886}
+//! ```
+//!
+//! Identity is read from the `X-Auth-Request-User` / `X-Auth-Request-Email`
+//! headers that an authenticating reverse proxy (e.g. oauth2-proxy with
+//! `set_xauthrequest`) injects. DICOM-RST itself performs no authentication
+//! (see #15/#42): these fields are TRUSTWORTHY ONLY when the deployment
+//! guarantees that the proxy is the sole ingress. The record is emitted
+//! regardless — an absent identity is itself audit-relevant.
+//!
+//! Delivery is FAIL-OPEN by design: records flow through a bounded channel
+//! to a writer task; when the buffer is full the record is dropped, a
+//! counter increments and a warning is logged — a slow disk or collector
+//! never blocks request handling. Deployments with stricter requirements
+//! should alert on the drop warnings.
+
+use std::io::Write;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use axum::extract::{RawPathParams, Request, State};
+use axum::middleware::Next;
+use axum::response::Response;
+use chrono::{SecondsFormat, Utc};
+use serde::Serialize;
+use tokio::sync::mpsc;
+use tracing::warn;
+
+/// 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,
+ /// `X-Auth-Request-Email` from the authenticating proxy, if present.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user: Option,
+ /// `X-Auth-Request-User` (the OIDC subject), if present.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub subject: Option,
+ /// First `X-Forwarded-For` entry, if present.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ pub method: String,
+ /// Full request path and query. 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,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_agent: Option,
+}
+
+/// Cloneable handle to the audit writer. `None` inside means auditing is
+/// disabled and the middleware is a no-op.
+#[derive(Clone)]
+pub struct AuditSink {
+ tx: Option>,
+}
+
+/// Records dropped because the buffer was full (fail-open pressure valve).
+static DROPPED: AtomicU64 = AtomicU64::new(0);
+
+const BUFFER: usize = 1024;
+
+impl AuditSink {
+ /// Create the sink and, when enabled, spawn the stdout writer task.
+ pub fn new(enabled: bool) -> Self {
+ if !enabled {
+ return Self { tx: None };
+ }
+ let (tx, mut rx) = mpsc::channel::(BUFFER);
+ tokio::spawn(async move {
+ // One locked write per record keeps lines atomic alongside the
+ // regular tracing output on the same stream.
+ while let Some(record) = rx.recv().await {
+ match serde_json::to_string(&record) {
+ Ok(mut line) => {
+ line.push('\n');
+ let mut stdout = std::io::stdout().lock();
+ let _ = stdout.write_all(line.as_bytes());
+ }
+ Err(err) => warn!("failed to serialize audit record: {err}"),
+ }
+ }
+ });
+ Self { tx: Some(tx) }
+ }
+
+ fn emit(&self, record: AuditRecord) {
+ let Some(tx) = &self.tx else { return };
+ if tx.try_send(record).is_err() {
+ let dropped = DROPPED.fetch_add(1, Ordering::Relaxed) + 1;
+ // Every drop is a warning-worthy event, but do not spam a
+ // saturated system: log the first and then every 100th.
+ if dropped == 1 || dropped.is_multiple_of(100) {
+ warn!("audit buffer full: {dropped} record(s) dropped so far (fail-open)");
+ }
+ }
+ }
+}
+
+/// Axum middleware producing one [`AuditRecord`] per request.
+///
+/// Attach with `axum::middleware::from_fn_with_state(sink, audit::middleware)`
+/// OUTSIDE the timeout layer, so timed-out requests are recorded with their
+/// 408 as well.
+pub async fn middleware(
+ State(sink): State,
+ params: RawPathParams,
+ request: Request,
+ next: Next,
+) -> Response {
+ if sink.tx.is_none() {
+ return next.run(request).await;
+ }
+
+ let mut aet = None;
+ let mut study = None;
+ let mut series = None;
+ let mut instance = None;
+ for (name, value) in ¶ms {
+ match name {
+ "aet" => aet = Some(value.to_owned()),
+ "study" => study = Some(value.to_owned()),
+ "series" => series = Some(value.to_owned()),
+ "instance" => instance = Some(value.to_owned()),
+ _ => {}
+ }
+ }
+
+ // Extract everything BEFORE the await, inside a block that ends first:
+ // a closure borrowing `&Request` held across `next.run().await` makes
+ // the future `!Send` (axum's `Body` is `!Sync`), failing the middleware
+ // `Service` bound with a famously opaque error.
+ let (user, subject, source, user_agent) = {
+ let headers = request.headers();
+ let get = |name: &str| {
+ headers
+ .get(name)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned)
+ };
+ (
+ get("x-auth-request-email"),
+ get("x-auth-request-user"),
+ get("x-forwarded-for").map(|forwarded| {
+ forwarded
+ .split(',')
+ .next()
+ .unwrap_or_default()
+ .trim()
+ .to_owned()
+ }),
+ get("user-agent"),
+ )
+ };
+ let method = request.method().to_string();
+ let path = request.uri().to_string();
+
+ let started = std::time::Instant::now();
+ let response = next.run(request).await;
+
+ sink.emit(AuditRecord {
+ audit: "http-access",
+ ts: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
+ user,
+ subject,
+ source,
+ method,
+ path,
+ aet,
+ study,
+ series,
+ instance,
+ status: response.status().as_u16(),
+ duration_ms: started.elapsed().as_millis(),
+ user_agent,
+ });
+
+ response
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn record_serializes_without_absent_fields() {
+ let record = AuditRecord {
+ audit: "http-access",
+ ts: "2026-08-17T00:00:00Z".to_owned(),
+ user: None,
+ subject: None,
+ source: None,
+ method: "GET".to_owned(),
+ path: "/aets".to_owned(),
+ aet: None,
+ study: None,
+ series: None,
+ instance: None,
+ status: 200,
+ duration_ms: 3,
+ user_agent: None,
+ };
+ let json = serde_json::to_string(&record).expect("serialize");
+ assert!(
+ !json.contains("user"),
+ "absent fields must be omitted: {json}"
+ );
+ assert!(json.contains("\"audit\":\"http-access\""));
+ }
+}
diff --git a/src/backend/dimse/cmove/movescu.rs b/src/backend/dimse/cmove/movescu.rs
index c99f611..1cc087a 100644
--- a/src/backend/dimse/cmove/movescu.rs
+++ b/src/backend/dimse/cmove/movescu.rs
@@ -25,6 +25,15 @@ impl MoveServiceClassUser {
#[instrument(skip_all, name = "MOVE-SCU")]
#[allow(clippy::significant_drop_tightening)]
pub async fn invoke(&self, request: CompositeMoveRequest) -> Result<(), MoveError> {
+ // Surface WHICH study the C-MOVE concerns — the audit trail needs
+ // more than "a move happened".
+ let study_uid = request
+ .identifier
+ .element(tags::STUDY_INSTANCE_UID)
+ .ok()
+ .and_then(|element| element.to_str().ok())
+ .map(|uid| uid.trim_end_matches('\0').to_owned())
+ .unwrap_or_default();
let association = self
.pool
.get(PresentationParameter {
@@ -54,7 +63,7 @@ impl MoveServiceClassUser {
match status_type {
StatusType::Success => {
- info!("C-MOVE completed successfully");
+ info!(study_uid, "C-MOVE completed successfully");
break;
}
StatusType::Pending => {
diff --git a/src/config/mod.rs b/src/config/mod.rs
index d7047ef..7986c52 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -340,6 +340,9 @@ pub struct TelemetryConfig {
pub sentry: Option,
#[serde(deserialize_with = "deserialize_log_level")]
pub level: tracing::Level,
+ /// Structured access-audit logging — see [`crate::audit`].
+ #[serde(default)]
+ pub audit: AuditConfig,
}
impl Default for TelemetryConfig {
@@ -347,10 +350,24 @@ impl Default for TelemetryConfig {
Self {
sentry: None,
level: tracing::Level::INFO,
+ audit: AuditConfig::default(),
}
}
}
+/// Configuration for the structured access-audit log ([`crate::audit`]).
+///
+/// Disabled by default: enabling it emits one JSON line per HTTP request on
+/// stdout, carrying the identity injected by an authenticating reverse proxy
+/// (`X-Auth-Request-*`) plus the DICOM resource coordinates. Delivery is
+/// fail-open (bounded buffer, drops are counted and logged).
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct AuditConfig {
+ #[serde(default)]
+ pub enabled: bool,
+}
+
/// Deserializer for [`tracing::Level`] as it does not implement [Deserialize]
fn deserialize_log_level<'de, D>(deserializer: D) -> Result
where
diff --git a/src/main.rs b/src/main.rs
index 6c23b68..7922b76 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
#![allow(clippy::multiple_crate_versions)]
pub(crate) mod api;
+pub(crate) mod audit;
pub(crate) mod backend;
pub(crate) mod config;
pub(crate) mod rendering;
@@ -17,6 +18,7 @@ use axum::extract::{DefaultBodyLimit, Request};
use axum::http::StatusCode;
use axum::response::Response;
use axum::ServiceExt;
+use std::io::IsTerminal;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::net::TcpListener;
@@ -46,6 +48,9 @@ fn init_logger(level: tracing::Level) {
.with(
tracing_subscriber::fmt::layer()
.compact()
+ // ANSI escapes belong on terminals, not in collected pod
+ // logs (they garble downstream log pipelines).
+ .with_ansi(std::io::stdout().is_terminal())
.with_file(false)
.with_line_number(false)
.with_target(false),
@@ -134,6 +139,8 @@ async fn run(config: AppConfig) -> anyhow::Result<()> {
});
}
+ let audit_sink = audit::AuditSink::new(config.telemetry.audit.enabled);
+
let app = api::routes(&config.server.http.base_path)
.layer(CorsLayer::permissive())
.layer(axum::middleware::from_fn(add_common_headers))
@@ -148,6 +155,12 @@ async fn run(config: AppConfig) -> anyhow::Result<()> {
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(config.server.http.request_timeout),
))
+ // Outside the timeout layer, so timed-out requests are audited
+ // with their 408 as well. No-op unless telemetry.audit.enabled.
+ .layer(axum::middleware::from_fn_with_state(
+ audit_sink,
+ audit::middleware,
+ ))
.with_state(app_state);
let app = NormalizePathLayer::trim_trailing_slash().layer(app);