1use std::{
4 future::Future,
5 net::{IpAddr, SocketAddr},
6 sync::Arc,
7};
8
9use axum::extract::FromRequestParts;
10use http::{HeaderMap, Method, request::Parts};
11use serde::Serialize;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ClientKind {
17 ApiKey,
19 Authenticated,
21 Anonymous,
23}
24
25impl ClientKind {
26 pub const fn as_str(self) -> &'static str {
28 match self {
29 Self::ApiKey => "api_key",
30 Self::Authenticated => "authenticated",
31 Self::Anonymous => "anonymous",
32 }
33 }
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct RequestContext {
69 inner: Arc<RequestContextInner>,
70 method: Method,
71 client_kind: ClientKind,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
75struct RequestContextInner {
76 request_id: String,
77 correlation_id: CorrelationId,
78 route: Option<String>,
79 path: String,
80 trace_id: Option<String>,
81 span_id: Option<String>,
82 user_id: Option<String>,
83 tenant_id: Option<String>,
84 session_id: Option<String>,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
88enum CorrelationId {
89 None,
90 RequestId,
91 Explicit(String),
92}
93
94impl CorrelationId {
95 fn as_str<'a>(&'a self, request_id: &'a str) -> Option<&'a str> {
96 match self {
97 Self::None => None,
98 Self::RequestId => Some(request_id),
99 Self::Explicit(value) => Some(value),
100 }
101 }
102}
103
104impl RequestContext {
105 pub fn new(request_id: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
111 Self {
112 inner: Arc::new(RequestContextInner {
113 request_id: request_id.into(),
114 correlation_id: CorrelationId::None,
115 route: None,
116 path: path.into(),
117 trace_id: None,
118 span_id: None,
119 user_id: None,
120 tenant_id: None,
121 session_id: None,
122 }),
123 method,
124 client_kind: ClientKind::Anonymous,
125 }
126 }
127
128 pub fn from_parts(parts: &Parts, request_id: impl Into<String>) -> Self {
135 let request_id = request_id.into();
136 let correlation_id = match header_to_string(&parts.headers, "x-correlation-id") {
137 Some(value) => CorrelationId::Explicit(value),
138 None if request_id.is_empty() => CorrelationId::None,
139 None => CorrelationId::RequestId,
140 };
141 let mut context = Self::new(request_id, parts.method.clone(), parts.uri.path());
142 let inner = Arc::make_mut(&mut context.inner);
143 inner.correlation_id = correlation_id;
144 inner.route = parts
145 .extensions
146 .get::<axum::extract::MatchedPath>()
147 .map(|path| path.as_str().to_owned());
148 context.client_kind = infer_client_kind(&parts.headers);
149 if let Some(trace_context) = parts
150 .headers
151 .get("traceparent")
152 .and_then(|value| value.to_str().ok())
153 .and_then(parse_traceparent)
154 {
155 inner.trace_id = Some(trace_context.trace_id.to_owned());
156 inner.span_id = Some(trace_context.parent_id.to_owned());
157 }
158 context
159 }
160
161 pub fn request_id(&self) -> &str {
166 &self.inner.request_id
167 }
168
169 pub(crate) fn into_request_id(self) -> String {
170 match Arc::try_unwrap(self.inner) {
171 Ok(inner) => inner.request_id,
172 Err(inner) => inner.request_id.clone(),
173 }
174 }
175
176 pub fn correlation_id(&self) -> Option<&str> {
181 self.inner.correlation_id.as_str(&self.inner.request_id)
182 }
183
184 pub const fn method(&self) -> &Method {
186 &self.method
187 }
188
189 pub fn route(&self) -> Option<&str> {
195 self.inner.route.as_deref()
196 }
197
198 pub fn path(&self) -> &str {
200 &self.inner.path
201 }
202
203 pub fn trace_id(&self) -> Option<&str> {
207 self.inner.trace_id.as_deref()
208 }
209
210 pub fn span_id(&self) -> Option<&str> {
212 self.inner.span_id.as_deref()
213 }
214
215 pub const fn client_kind(&self) -> ClientKind {
220 self.client_kind
221 }
222
223 pub fn user_id(&self) -> Option<&str> {
225 self.inner.user_id.as_deref()
226 }
227
228 pub fn tenant_id(&self) -> Option<&str> {
230 self.inner.tenant_id.as_deref()
231 }
232
233 pub fn session_id(&self) -> Option<&str> {
235 self.inner.session_id.as_deref()
236 }
237
238 pub fn with_route(mut self, route: impl Into<String>) -> Self {
240 Arc::make_mut(&mut self.inner).route = Some(route.into());
241 self
242 }
243
244 pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
246 Arc::make_mut(&mut self.inner).user_id = Some(user_id.into());
247 self
248 }
249
250 pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
252 Arc::make_mut(&mut self.inner).tenant_id = Some(tenant_id.into());
253 self
254 }
255
256 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
258 Arc::make_mut(&mut self.inner).session_id = Some(session_id.into());
259 self
260 }
261}
262
263impl<S> FromRequestParts<S> for RequestContext
264where
265 S: Send + Sync,
266{
267 type Rejection = axum::http::StatusCode;
268
269 fn from_request_parts(
270 parts: &mut Parts,
271 _state: &S,
272 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
273 let context = parts.extensions.get::<Self>().cloned();
274 async move { context.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR) }
275 }
276}
277
278#[derive(Clone, Debug, Eq, Hash, PartialEq)]
280pub struct RequestIdentity(String);
281
282impl RequestIdentity {
283 pub fn new(value: impl Into<String>) -> Self {
285 Self(value.into())
286 }
287
288 pub fn as_str(&self) -> &str {
290 &self.0
291 }
292}
293
294pub trait IdentityExtractor: Clone + Send + Sync + 'static {
296 fn extract(&self, parts: &Parts) -> Option<RequestIdentity>;
298}
299
300impl<F> IdentityExtractor for F
301where
302 F: Fn(&Parts) -> Option<RequestIdentity> + Clone + Send + Sync + 'static,
303{
304 fn extract(&self, parts: &Parts) -> Option<RequestIdentity> {
305 self(parts)
306 }
307}
308
309pub fn context_identity() -> impl IdentityExtractor {
311 |parts: &Parts| {
312 if let Some(context) = parts.extensions.get::<RequestContext>()
313 && let Some(value) = context.user_id().or_else(|| context.tenant_id())
314 {
315 return Some(RequestIdentity::new(value.to_owned()));
316 }
317 header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
318 }
319}
320
321pub fn api_key_identity() -> impl IdentityExtractor {
323 |parts: &Parts| header_to_string(&parts.headers, "x-api-key").map(RequestIdentity::new)
324}
325
326pub fn client_ip_identity() -> impl IdentityExtractor {
333 |parts: &Parts| {
334 peer_ip(parts)
335 .map(|ip| RequestIdentity::new(ip.to_string()))
336 .or_else(|| Some(RequestIdentity::new("anonymous")))
337 }
338}
339
340pub fn trusted_proxy_client_ip_identity(
353 trusted_proxies: impl IntoIterator<Item = IpAddr>,
354) -> impl IdentityExtractor {
355 let trusted_proxies: Arc<[IpAddr]> = trusted_proxies.into_iter().collect::<Vec<_>>().into();
356 move |parts: &Parts| {
357 peer_ip(parts)
358 .map(|peer| {
359 let client_ip =
360 trusted_forwarded_client_ip(&parts.headers, peer, trusted_proxies.as_ref());
361 RequestIdentity::new(client_ip.to_string())
362 })
363 .or_else(|| Some(RequestIdentity::new("anonymous")))
364 }
365}
366
367fn peer_ip(parts: &Parts) -> Option<IpAddr> {
368 parts
369 .extensions
370 .get::<axum::extract::ConnectInfo<SocketAddr>>()
371 .map(|connect| connect.0.ip())
372}
373
374fn trusted_forwarded_client_ip(
375 headers: &HeaderMap,
376 peer: IpAddr,
377 trusted_proxies: &[IpAddr],
378) -> IpAddr {
379 let mut client_ip = peer;
380 'chain: for value in headers.get_all("x-forwarded-for").iter().rev() {
381 let Ok(value) = value.to_str() else {
382 break;
383 };
384 for hop in value.rsplit(',') {
385 if !trusted_proxies.contains(&client_ip) {
386 break 'chain;
387 }
388 let Ok(hop) = hop.trim().parse() else {
389 break 'chain;
390 };
391 client_ip = hop;
392 }
393 }
394 client_ip
395}
396
397pub(crate) fn header_to_string(headers: &HeaderMap, name: &'static str) -> Option<String> {
398 header_to_str(headers, name).map(str::to_owned)
399}
400
401pub(crate) fn header_to_str<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
402 headers
403 .get(name)
404 .and_then(|value| value.to_str().ok())
405 .filter(|value| !value.is_empty())
406}
407
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub(crate) struct ParsedTraceParent<'a> {
410 pub(crate) trace_id: &'a str,
411 pub(crate) parent_id: &'a str,
412 pub(crate) flags: u8,
413}
414
415pub(crate) fn parse_traceparent(value: &str) -> Option<ParsedTraceParent<'_>> {
416 let bytes = value.as_bytes();
417 if bytes.len() < 55 || bytes[2] != b'-' || bytes[35] != b'-' || bytes[52] != b'-' {
418 return None;
419 }
420
421 let version = &bytes[..2];
422 let trace_id = &bytes[3..35];
423 let parent_id = &bytes[36..52];
424 let flags = &bytes[53..55];
425 if !is_lower_hex(version)
426 || version == b"ff"
427 || !is_valid_id(trace_id)
428 || !is_valid_id(parent_id)
429 || !is_lower_hex(flags)
430 || (version == b"00" && bytes.len() != 55)
431 || (bytes.len() > 55 && bytes[55] != b'-')
432 {
433 return None;
434 }
435
436 Some(ParsedTraceParent {
437 trace_id: &value[3..35],
438 parent_id: &value[36..52],
439 flags: u8::from_str_radix(&value[53..55], 16).ok()?,
440 })
441}
442
443fn is_valid_id(value: &[u8]) -> bool {
444 is_lower_hex(value) && value.iter().any(|byte| *byte != b'0')
445}
446
447fn is_lower_hex(value: &[u8]) -> bool {
448 value
449 .iter()
450 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
451}
452
453fn infer_client_kind(headers: &HeaderMap) -> ClientKind {
454 if headers.contains_key("x-api-key") {
455 ClientKind::ApiKey
456 } else if headers.contains_key(http::header::AUTHORIZATION) {
457 ClientKind::Authenticated
458 } else {
459 ClientKind::Anonymous
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use http::Request;
467
468 #[test]
469 fn request_context_can_consume_request_id() {
470 let context = RequestContext::new("req-123", Method::GET, "/users");
471
472 assert_eq!(context.into_request_id(), "req-123");
473 }
474
475 #[test]
476 fn request_context_new_does_not_implicitly_set_a_correlation_id() {
477 let context = RequestContext::new("req-123", Method::GET, "/users");
478
479 assert_eq!(context.correlation_id(), None);
480 assert_eq!(context.inner.correlation_id, CorrelationId::None);
481 }
482
483 #[test]
484 fn request_context_from_parts_reuses_request_id_as_correlation_fallback() {
485 let (parts, ()) = Request::builder()
486 .uri("/users")
487 .body(())
488 .unwrap()
489 .into_parts();
490
491 let context = RequestContext::from_parts(&parts, "req-123");
492
493 assert_eq!(context.correlation_id(), Some("req-123"));
494 assert_eq!(context.inner.correlation_id, CorrelationId::RequestId);
495 }
496
497 #[test]
498 fn request_context_from_parts_preserves_explicit_correlation_id() {
499 let (parts, ()) = Request::builder()
500 .uri("/users")
501 .header("x-correlation-id", "corr-456")
502 .body(())
503 .unwrap()
504 .into_parts();
505
506 let context = RequestContext::from_parts(&parts, "req-123");
507
508 assert_eq!(context.correlation_id(), Some("corr-456"));
509 assert_eq!(
510 context.inner.correlation_id,
511 CorrelationId::Explicit("corr-456".to_owned())
512 );
513 }
514
515 #[test]
516 fn request_context_from_parts_keeps_empty_ids_absent() {
517 let (parts, ()) = Request::builder()
518 .uri("/users")
519 .body(())
520 .unwrap()
521 .into_parts();
522
523 let context = RequestContext::from_parts(&parts, "");
524
525 assert_eq!(context.correlation_id(), None);
526 assert_eq!(context.inner.correlation_id, CorrelationId::None);
527 }
528
529 #[test]
530 fn cloned_request_context_uses_copy_on_write_for_enrichment() {
531 let original = RequestContext::new("req-123", Method::GET, "/users");
532 let shared = original.clone();
533 assert!(Arc::ptr_eq(&original.inner, &shared.inner));
534
535 let enriched = shared
536 .with_route("/users/{id}")
537 .with_user_id("user-42")
538 .with_tenant_id("tenant-7")
539 .with_session_id("session-9");
540
541 assert!(!Arc::ptr_eq(&original.inner, &enriched.inner));
542 assert_eq!(original.route(), None);
543 assert_eq!(original.user_id(), None);
544 assert_eq!(enriched.route(), Some("/users/{id}"));
545 assert_eq!(enriched.user_id(), Some("user-42"));
546 assert_eq!(enriched.tenant_id(), Some("tenant-7"));
547 assert_eq!(enriched.session_id(), Some("session-9"));
548 }
549
550 #[test]
551 fn request_context_extracts_valid_trace_and_parent_span_ids() {
552 let (parts, ()) = Request::builder()
553 .uri("/users/42")
554 .header(
555 "traceparent",
556 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
557 )
558 .body(())
559 .unwrap()
560 .into_parts();
561
562 let context = RequestContext::from_parts(&parts, "request-1");
563
564 assert_eq!(context.trace_id(), Some("4bf92f3577b34da6a3ce929d0e0e4736"));
565 assert_eq!(context.span_id(), Some("00f067aa0ba902b7"));
566 }
567
568 #[test]
569 fn request_context_ignores_invalid_traceparent_values() {
570 for traceparent in [
571 "not-a-traceparent",
572 "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
573 "00-00000000000000000000000000000000-00f067aa0ba902b7-01",
574 "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",
575 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra",
576 ] {
577 let (parts, ()) = Request::builder()
578 .uri("/")
579 .header("traceparent", traceparent)
580 .body(())
581 .unwrap()
582 .into_parts();
583 let context = RequestContext::from_parts(&parts, "request-1");
584
585 assert_eq!(context.trace_id(), None, "{traceparent}");
586 assert_eq!(context.span_id(), None, "{traceparent}");
587 }
588 }
589
590 #[test]
591 fn client_ip_identity_ignores_forwarded_headers_without_peer_info() {
592 let parts = request_parts(None, Some("203.0.113.10"));
593 let identity = client_ip_identity().extract(&parts).unwrap();
594
595 assert_eq!(identity.as_str(), "anonymous");
596 }
597
598 #[test]
599 fn trusted_proxy_client_ip_identity_uses_forwarded_header_from_trusted_peer() {
600 let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10, 10.0.0.5"));
601 let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
602 let intermediate_proxy = "10.0.0.5".parse::<IpAddr>().unwrap();
603 let identity = trusted_proxy_client_ip_identity([trusted_proxy, intermediate_proxy])
604 .extract(&parts)
605 .unwrap();
606
607 assert_eq!(identity.as_str(), "203.0.113.10");
608 }
609
610 #[test]
611 fn trusted_proxy_client_ip_identity_rejects_untrusted_forwarded_prefixes() {
612 let parts = request_parts(Some("127.0.0.1:5000"), Some("198.51.100.200, 203.0.113.10"));
613 let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
614 let identity = trusted_proxy_client_ip_identity([trusted_proxy])
615 .extract(&parts)
616 .unwrap();
617
618 assert_eq!(identity.as_str(), "203.0.113.10");
619 }
620
621 #[test]
622 fn trusted_proxy_client_ip_identity_scans_all_forwarded_header_values() {
623 let mut parts = request_parts(Some("127.0.0.1:5000"), Some("198.51.100.200"));
624 parts
625 .headers
626 .append("x-forwarded-for", "203.0.113.10".parse().unwrap());
627 let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
628 let identity = trusted_proxy_client_ip_identity([trusted_proxy])
629 .extract(&parts)
630 .unwrap();
631
632 assert_eq!(identity.as_str(), "203.0.113.10");
633 }
634
635 #[test]
636 fn trusted_proxy_client_ip_identity_stops_at_malformed_forwarded_hop() {
637 let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10, not-an-ip"));
638 let trusted_proxy = "127.0.0.1".parse::<IpAddr>().unwrap();
639 let identity = trusted_proxy_client_ip_identity([trusted_proxy])
640 .extract(&parts)
641 .unwrap();
642
643 assert_eq!(identity.as_str(), "127.0.0.1");
644 }
645
646 #[test]
647 fn trusted_proxy_client_ip_identity_ignores_forwarded_header_from_untrusted_peer() {
648 let parts = request_parts(Some("127.0.0.1:5000"), Some("203.0.113.10"));
649 let trusted_proxy = "10.0.0.1".parse::<IpAddr>().unwrap();
650 let identity = trusted_proxy_client_ip_identity([trusted_proxy])
651 .extract(&parts)
652 .unwrap();
653
654 assert_eq!(identity.as_str(), "127.0.0.1");
655 }
656
657 fn request_parts(peer: Option<&str>, forwarded_for: Option<&str>) -> Parts {
658 let mut builder = Request::builder().uri("/");
659 if let Some(forwarded_for) = forwarded_for {
660 builder = builder.header("x-forwarded-for", forwarded_for);
661 }
662 let (mut parts, ()) = builder.body(()).unwrap().into_parts();
663 if let Some(peer) = peer {
664 parts.extensions.insert(axum::extract::ConnectInfo(
665 peer.parse::<SocketAddr>().unwrap(),
666 ));
667 }
668 parts
669 }
670}