use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{Method, StatusCode},
middleware::Next,
response::Response,
};
#[derive(Debug, Clone, Default)]
pub struct AuditConfig {
pub trust_proxy_headers: bool,
}
pub async fn audit_middleware(
State(cfg): State<Arc<AuditConfig>>,
req: Request,
next: Next,
) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let ip = extract_client_ip(&req, cfg.trust_proxy_headers);
let user_agent = extract_user_agent(&req);
let response = next.run(req).await;
let status = response.status();
if should_audit(&method, status) {
::tracing::info!(
audit = true,
event = classify_event(&method, status),
method = %method,
path = %path,
status = status.as_u16(),
ip = %ip,
user_agent = %user_agent,
"Security audit event"
);
}
response
}
pub fn should_audit(method: &Method, status: StatusCode) -> bool {
status == StatusCode::UNAUTHORIZED
|| status == StatusCode::FORBIDDEN
|| status == StatusCode::TOO_MANY_REQUESTS
|| is_mutation(method)
}
fn is_mutation(method: &Method) -> bool {
matches!(
*method,
Method::POST | Method::PUT | Method::PATCH | Method::DELETE
)
}
pub fn classify_event(method: &Method, status: StatusCode) -> &'static str {
match status {
StatusCode::UNAUTHORIZED => "auth_failure",
StatusCode::FORBIDDEN => "access_denied",
StatusCode::TOO_MANY_REQUESTS => "rate_limited",
_ => match *method {
Method::POST => "data_create",
Method::PUT | Method::PATCH => "data_update",
Method::DELETE => "data_delete",
_ => "unknown",
},
}
}
pub fn extract_client_ip(req: &Request, trust_proxy_headers: bool) -> String {
if !trust_proxy_headers {
return "unknown".to_string();
}
let first_xff_hop = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
first_xff_hop
.or_else(|| {
req.headers()
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
})
.unwrap_or_else(|| "unknown".to_string())
}
pub fn extract_user_agent(req: &Request) -> String {
req.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn req_with(headers: &[(&str, &str)]) -> Request {
let mut builder = Request::builder().uri("/");
for (k, v) in headers {
builder = builder.header(*k, HeaderValue::from_str(v).unwrap());
}
builder.body(axum::body::Body::empty()).unwrap()
}
#[test]
fn audits_auth_failure() {
assert!(should_audit(&Method::GET, StatusCode::UNAUTHORIZED));
assert!(should_audit(&Method::GET, StatusCode::FORBIDDEN));
}
#[test]
fn audits_rate_limit() {
assert!(should_audit(&Method::GET, StatusCode::TOO_MANY_REQUESTS));
}
#[test]
fn audits_mutations_regardless_of_status() {
assert!(should_audit(&Method::POST, StatusCode::OK));
assert!(should_audit(&Method::PUT, StatusCode::CREATED));
assert!(should_audit(&Method::PATCH, StatusCode::OK));
assert!(should_audit(&Method::DELETE, StatusCode::NO_CONTENT));
}
#[test]
fn skips_safe_reads_with_2xx() {
assert!(!should_audit(&Method::GET, StatusCode::OK));
assert!(!should_audit(&Method::HEAD, StatusCode::OK));
}
#[test]
fn classify_event_status_takes_precedence_over_method() {
assert_eq!(
classify_event(&Method::POST, StatusCode::UNAUTHORIZED),
"auth_failure"
);
assert_eq!(
classify_event(&Method::POST, StatusCode::FORBIDDEN),
"access_denied"
);
assert_eq!(
classify_event(&Method::POST, StatusCode::TOO_MANY_REQUESTS),
"rate_limited"
);
}
#[test]
fn classify_event_maps_methods_to_crud_verbs() {
assert_eq!(classify_event(&Method::POST, StatusCode::OK), "data_create");
assert_eq!(classify_event(&Method::PUT, StatusCode::OK), "data_update");
assert_eq!(classify_event(&Method::PATCH, StatusCode::OK), "data_update");
assert_eq!(
classify_event(&Method::DELETE, StatusCode::OK),
"data_delete"
);
}
#[test]
fn ip_returns_unknown_when_proxy_not_trusted() {
let req = req_with(&[("x-forwarded-for", "1.2.3.4"), ("x-real-ip", "5.6.7.8")]);
assert_eq!(extract_client_ip(&req, false), "unknown");
}
#[test]
fn ip_uses_first_xff_hop_when_trusted() {
let req = req_with(&[("x-forwarded-for", "1.2.3.4, 10.0.0.1, 10.0.0.2")]);
assert_eq!(extract_client_ip(&req, true), "1.2.3.4");
}
#[test]
fn ip_falls_through_to_real_ip_when_xff_missing() {
let req = req_with(&[("x-real-ip", "9.9.9.9")]);
assert_eq!(extract_client_ip(&req, true), "9.9.9.9");
}
#[test]
fn ip_falls_through_to_real_ip_when_xff_blank() {
let req = req_with(&[("x-forwarded-for", " "), ("x-real-ip", "9.9.9.9")]);
assert_eq!(extract_client_ip(&req, true), "9.9.9.9");
}
#[test]
fn ip_unknown_when_no_headers_present() {
let req = req_with(&[]);
assert_eq!(extract_client_ip(&req, true), "unknown");
}
}