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// public:
46/// Response + request header name. Lower-case to keep parity with the
47/// HTTP/2 wire format and to match what other observability tooling
48/// (OpenTelemetry, Cloudflare, etc.) writes.
49pub const CORRELATION_ID_HEADER: &str = "x-correlation-id";
50
51// public:
52/// Wrapper carried in the request context so handlers can pull it
53/// out via `req.ctx().get::<CorrelationId>()`. The inner string is
54/// the UUID rendered in hyphenated lowercase form.
55#[derive(Debug, Clone)]
56pub struct CorrelationId(pub String);
57
58impl CorrelationId {
59 // public:
60 /// Borrow the underlying id string.
61 pub fn as_str(&self) -> &str {
62 &self.0
63 }
64}
65
66// public:
67/// Middleware: attach a UUID v7 to every request, surface it in the
68/// response, and stash it in the request context for the audit
69/// pipeline to pick up.
70///
71/// Honours an inbound `x-correlation-id` header so a proxy or test
72/// harness can pin the id from outside. Only accepts values that
73/// look like a UUID (rough sanity: between 16 and 64 chars, no
74/// whitespace, no control bytes); anything else is replaced with a
75/// fresh UUID v7 so a malicious sender can't poison the audit trail
76/// with adversarial strings.
77pub async fn correlation_id(mut req: Request, next: Next) -> Result<Response> {
78 let id = inbound_id_or_fresh(req.header(CORRELATION_ID_HEADER));
79 req.ctx_mut().insert(CorrelationId(id.clone()));
80 let mut resp = next.run(req).await?;
81 resp = resp.with_header(CORRELATION_ID_HEADER, id);
82 Ok(resp)
83}
84
85/// Either trust an inbound id (when it looks safe) or mint a fresh
86/// time-sortable UUID v7. Pulled out for unit testing.
87fn inbound_id_or_fresh(header: Option<&str>) -> String {
88 if let Some(raw) = header {
89 let trimmed = raw.trim();
90 if looks_like_id(trimmed) {
91 return trimmed.to_string();
92 }
93 }
94 Uuid::now_v7().hyphenated().to_string()
95}
96
97fn looks_like_id(s: &str) -> bool {
98 if s.is_empty() || s.len() > 64 || s.len() < 16 {
99 return false;
100 }
101 s.chars()
102 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn fresh_id_is_uuid_v7_shape() {
111 let id = inbound_id_or_fresh(None);
112 // 36 chars, hyphenated, version 7.
113 assert_eq!(id.len(), 36);
114 let uuid = Uuid::parse_str(&id).expect("valid uuid");
115 assert_eq!(uuid.get_version(), Some(uuid::Version::SortRand));
116 }
117
118 #[test]
119 fn inbound_safe_value_is_kept() {
120 // A reasonable-looking inbound id passes through.
121 let inbound = "01910e4f-4b0a-7e62-8d87-1e7a09c3b1aa";
122 let out = inbound_id_or_fresh(Some(inbound));
123 assert_eq!(out, inbound);
124 }
125
126 #[test]
127 fn inbound_with_whitespace_is_trimmed() {
128 let inbound = " 01910e4f-4b0a-7e62-8d87-1e7a09c3b1aa ";
129 let out = inbound_id_or_fresh(Some(inbound));
130 assert_eq!(out, inbound.trim());
131 }
132
133 #[test]
134 fn inbound_too_short_is_replaced() {
135 let out = inbound_id_or_fresh(Some("short"));
136 assert_ne!(out, "short");
137 assert!(Uuid::parse_str(&out).is_ok());
138 }
139
140 #[test]
141 fn inbound_too_long_is_replaced() {
142 let evil = "x".repeat(200);
143 let out = inbound_id_or_fresh(Some(&evil));
144 assert_ne!(out, evil);
145 }
146
147 #[test]
148 fn inbound_with_control_chars_is_replaced() {
149 // Spaces, newlines, ANSI escapes — any are rejected.
150 for evil in [
151 "abc def 1234567890",
152 "abc\ndef-1234567890",
153 "\x1b[31mevil\x1b[0m-1234",
154 ] {
155 let out = inbound_id_or_fresh(Some(evil));
156 assert_ne!(out, evil, "header {evil:?} should have been replaced");
157 }
158 }
159
160 #[test]
161 fn fresh_ids_are_unique() {
162 // Two fresh ids back-to-back must differ — ensures the v7
163 // mint isn't returning a stale singleton.
164 assert_ne!(inbound_id_or_fresh(None), inbound_id_or_fresh(None));
165 }
166}