Skip to main content

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}