rustio_admin/middleware/correlation_id.rs
1//! Per-request correlation identifier.
2//!
3//! Doctrine 8: audit logs must be forensically useful. Every audit
4//! row written under a single HTTP request shares one `correlation_id`
5//! so a future `/admin/history/<id>` page can reconstruct the chain
6//! ("admin reset password → all sessions revoked → security email
7//! dispatched"). The id is also returned to clients in the
8//! `x-correlation-id` response header so external consumers (proxy
9//! logs, browser dev tools, support tickets) can pivot through the
10//! same trail.
11//!
12//! ## Placement in the middleware chain
13//!
14//! This middleware **must** sit before [`csrf_protect`](super::csrf_protect)
15//! so that even rejected requests (403 from CSRF, 429 from rate-limit)
16//! are emitted with a correlation id. That is intentional: every
17//! reasonable observability story for an admin panel needs the
18//! "request 7f… was blocked by CSRF" trace, not just successful
19//! requests.
20//!
21//! Recommended ordering inside `Router::middleware`:
22//!
23//! ```ignore
24//! Router::new()
25//! .middleware(middleware::logger)
26//! .middleware(middleware::correlation_id) // ← before csrf_protect
27//! .middleware(middleware::security_headers)
28//! .middleware(middleware::csrf_protect)
29//! ```
30//!
31//! ## Reading the id from a handler
32//!
33//! ```ignore
34//! use rustio_admin::middleware::CorrelationId;
35//!
36//! let cid = req.ctx().get::<CorrelationId>().map(|c| c.0.as_str());
37//! ```
38
39use uuid::Uuid;
40
41use crate::error::Result;
42use crate::http::{Request, Response};
43use crate::router::Next;
44
45/// Response + request header name. Lower-case to keep parity with the
46/// HTTP/2 wire format and to match what other observability tooling
47/// (OpenTelemetry, Cloudflare, etc.) writes.
48pub const CORRELATION_ID_HEADER: &str = "x-correlation-id";
49
50/// Wrapper carried in the request context so handlers can pull it
51/// out via `req.ctx().get::<CorrelationId>()`. The inner string is
52/// the UUID rendered in hyphenated lowercase form.
53#[derive(Debug, Clone)]
54pub struct CorrelationId(pub String);
55
56impl CorrelationId {
57 /// Borrow the underlying id string.
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63/// Middleware: attach a UUID v7 to every request, surface it in the
64/// response, and stash it in the request context for the audit
65/// pipeline to pick up.
66///
67/// Honours an inbound `x-correlation-id` header so a proxy or test
68/// harness can pin the id from outside. Only accepts values that
69/// look like a UUID (rough sanity: between 16 and 64 chars, no
70/// whitespace, no control bytes); anything else is replaced with a
71/// fresh UUID v7 so a malicious sender can't poison the audit trail
72/// with adversarial strings.
73pub async fn correlation_id(mut req: Request, next: Next) -> Result<Response> {
74 let id = inbound_id_or_fresh(req.header(CORRELATION_ID_HEADER));
75 req.ctx_mut().insert(CorrelationId(id.clone()));
76 let mut resp = next.run(req).await?;
77 resp = resp.with_header(CORRELATION_ID_HEADER, id);
78 Ok(resp)
79}
80
81/// Either trust an inbound id (when it looks safe) or mint a fresh
82/// time-sortable UUID v7. Pulled out for unit testing.
83fn inbound_id_or_fresh(header: Option<&str>) -> String {
84 if let Some(raw) = header {
85 let trimmed = raw.trim();
86 if looks_like_id(trimmed) {
87 return trimmed.to_string();
88 }
89 }
90 Uuid::now_v7().hyphenated().to_string()
91}
92
93fn looks_like_id(s: &str) -> bool {
94 if s.is_empty() || s.len() > 64 || s.len() < 16 {
95 return false;
96 }
97 s.chars()
98 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn fresh_id_is_uuid_v7_shape() {
107 let id = inbound_id_or_fresh(None);
108 // 36 chars, hyphenated, version 7.
109 assert_eq!(id.len(), 36);
110 let uuid = Uuid::parse_str(&id).expect("valid uuid");
111 assert_eq!(uuid.get_version(), Some(uuid::Version::SortRand));
112 }
113
114 #[test]
115 fn inbound_safe_value_is_kept() {
116 // A reasonable-looking inbound id passes through.
117 let inbound = "01910e4f-4b0a-7e62-8d87-1e7a09c3b1aa";
118 let out = inbound_id_or_fresh(Some(inbound));
119 assert_eq!(out, inbound);
120 }
121
122 #[test]
123 fn inbound_with_whitespace_is_trimmed() {
124 let inbound = " 01910e4f-4b0a-7e62-8d87-1e7a09c3b1aa ";
125 let out = inbound_id_or_fresh(Some(inbound));
126 assert_eq!(out, inbound.trim());
127 }
128
129 #[test]
130 fn inbound_too_short_is_replaced() {
131 let out = inbound_id_or_fresh(Some("short"));
132 assert_ne!(out, "short");
133 assert!(Uuid::parse_str(&out).is_ok());
134 }
135
136 #[test]
137 fn inbound_too_long_is_replaced() {
138 let evil = "x".repeat(200);
139 let out = inbound_id_or_fresh(Some(&evil));
140 assert_ne!(out, evil);
141 }
142
143 #[test]
144 fn inbound_with_control_chars_is_replaced() {
145 // Spaces, newlines, ANSI escapes — any are rejected.
146 for evil in [
147 "abc def 1234567890",
148 "abc\ndef-1234567890",
149 "\x1b[31mevil\x1b[0m-1234",
150 ] {
151 let out = inbound_id_or_fresh(Some(evil));
152 assert_ne!(out, evil, "header {evil:?} should have been replaced");
153 }
154 }
155
156 #[test]
157 fn fresh_ids_are_unique() {
158 // Two fresh ids back-to-back must differ — ensures the v7
159 // mint isn't returning a stale singleton.
160 assert_ne!(inbound_id_or_fresh(None), inbound_id_or_fresh(None));
161 }
162}