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// 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}