manta_server/server/
auth_middleware.rs

1//! Defensive middleware for the `/api/v1/auth/*` sub-router.
2//!
3//! Two layers, applied in this order:
4//!
5//! 1. `rate_limit` — per-source-IP token-bucket. Drops requests that
6//!    exceed `[server].auth_rate_limit_per_minute` with a 429 response.
7//!    Source IP comes from the connection (after the optional
8//!    `X-Forwarded-For` handling that ConnectInfo gives us). Operators
9//!    are still expected to terminate at a reverse proxy and rate-limit
10//!    there too — this is defence in depth.
11//!
12//! 2. `strip_body_for_logs` — explicit, even though the request-logger
13//!    in `super::log_requests` only logs `method + uri + status` today.
14//!    Treat it as a hard guarantee that credentials submitted to
15//!    `/auth/token` never end up in a log line, regardless of what the
16//!    logger middleware grows into in future.
17
18use std::collections::HashMap;
19use std::net::{IpAddr, SocketAddr};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23use axum::{
24  Json,
25  extract::{ConnectInfo, Request, State},
26  http::StatusCode,
27  middleware::Next,
28  response::{IntoResponse, Response},
29};
30
31use super::ServerState;
32use super::handlers::ErrorResponse;
33
34/// Per-IP state for the token-bucket rate limiter.
35struct WindowState {
36  window_start: Instant,
37  count: u32,
38}
39
40/// In-memory rate-limit table, sized by the number of distinct
41/// source IPs that hit `/auth/*` in the last minute. For typical CLI
42/// fleets this is small; entries older than two windows are pruned
43/// on every check.
44///
45/// Constructed once by [`super::routes::build_router`] and threaded
46/// through Axum's `Extension` layer into [`rate_limit`]. The limit
47/// (requests per IP per minute) is read from
48/// [`super::ServerState::auth_rate_limit_per_minute`] on every
49/// request, so it can change at config-reload time without
50/// rebuilding the router.
51#[derive(Default)]
52pub struct AuthRateLimiter {
53  windows: Mutex<HashMap<IpAddr, WindowState>>,
54}
55
56impl AuthRateLimiter {
57  /// Construct a fresh limiter wrapped in an `Arc` so it can be
58  /// shared via Axum's `Extension` layer across handler invocations.
59  pub fn new() -> Arc<Self> {
60    Arc::new(Self::default())
61  }
62
63  /// Returns `true` if `ip` is allowed to make one more request under
64  /// the given `limit` (requests per minute), `false` if it would
65  /// exceed.
66  fn check(&self, ip: IpAddr, limit: u32) -> bool {
67    self.check_at(ip, limit, Instant::now())
68  }
69
70  /// Testable variant of [`Self::check`] with an explicit clock. The split
71  /// lets unit tests exercise the window-reset and pruning logic
72  /// without actually sleeping 60+ seconds.
73  fn check_at(&self, ip: IpAddr, limit: u32, now: Instant) -> bool {
74    let window = Duration::from_secs(60);
75    let mut windows = self.windows.lock().expect("rate limiter mutex poisoned");
76
77    // Opportunistic pruning of stale entries.
78    windows
79      .retain(|_, state| now.duration_since(state.window_start) < window * 2);
80
81    let entry = windows.entry(ip).or_insert(WindowState {
82      window_start: now,
83      count: 0,
84    });
85
86    if now.duration_since(entry.window_start) >= window {
87      entry.window_start = now;
88      entry.count = 0;
89    }
90
91    if entry.count >= limit {
92      return false;
93    }
94    entry.count += 1;
95    true
96  }
97}
98
99/// Per-source-IP rate-limit middleware for the `/api/v1/auth/*`
100/// sub-router. Reads
101/// [`super::ServerState::auth_rate_limit_per_minute`]; when `None`,
102/// the middleware is a no-op (operators rate-limit at the proxy).
103/// When the per-IP request count exceeds the limit, returns
104/// `429 Too Many Requests` with an [`ErrorResponse`] body and a
105/// `tracing::warn!` event.
106pub async fn rate_limit(
107  State(state): State<Arc<ServerState>>,
108  ConnectInfo(peer): ConnectInfo<SocketAddr>,
109  limiter: axum::extract::Extension<Arc<AuthRateLimiter>>,
110  request: Request,
111  next: Next,
112) -> Response {
113  let Some(limit) = state.auth_rate_limit_per_minute else {
114    return next.run(request).await;
115  };
116  if !limiter.check(peer.ip(), limit) {
117    tracing::warn!(
118      "auth: rate limit exceeded for source {} (limit={}/min)",
119      peer.ip(),
120      limit
121    );
122    return (
123      StatusCode::TOO_MANY_REQUESTS,
124      Json(ErrorResponse {
125        error: "rate limit exceeded".to_string(),
126      }),
127    )
128      .into_response();
129  }
130  next.run(request).await
131}
132
133/// Belt-and-braces: ensure no `/auth/*` request body ever reaches a
134/// logger. The runtime cost is one logger-scoped `tracing` span with
135/// the body field redacted; the body itself is forwarded to the
136/// handler untouched, so deserialisation in
137/// [`super::handlers::auth_token`] still sees the original payload.
138pub async fn strip_body_for_logs(request: Request, next: Next) -> Response {
139  let span = tracing::info_span!("auth_request", body = "<redacted>");
140  let _enter = span.enter();
141  next.run(request).await
142}
143
144/// Reject mutating requests when the caller's bearer token carries
145/// the [`READ_ONLY_ROLE`] role. Read methods (`GET`, `HEAD`,
146/// `OPTIONS`, …) pass through unconditionally.
147///
148/// Missing or malformed tokens also pass through — the handler's
149/// own [`super::handlers::BearerToken`] extractor produces the 401
150/// it already does. This middleware is **not** the authentication
151/// boundary.
152///
153/// Layered in [`super::routes::build_router`] only on the
154/// `/api/v1/*` resource router, not on `/api/v1/auth/*` (login
155/// flow) and not on `/docs` (Swagger UI).
156///
157/// [`READ_ONLY_ROLE`]: super::common::jwt_ops::READ_ONLY_ROLE
158pub async fn read_only_guard(request: Request, next: Next) -> Response {
159  use axum::http::{Method, header};
160
161  let m = request.method();
162  if !matches!(
163    m,
164    &Method::POST | &Method::PUT | &Method::PATCH | &Method::DELETE
165  ) {
166    return next.run(request).await;
167  }
168
169  let token_opt = request
170    .headers()
171    .get(header::AUTHORIZATION)
172    .and_then(|v| v.to_str().ok())
173    .and_then(|v| {
174      v.strip_prefix("Bearer ")
175        .or_else(|| v.strip_prefix("bearer "))
176    });
177
178  if let Some(token) = token_opt
179    && super::common::jwt_ops::has_role(
180      token,
181      super::common::jwt_ops::READ_ONLY_ROLE,
182    )
183  {
184    let method = m.clone();
185    let path = request.uri().path().to_string();
186    tracing::warn!(
187      "rejecting {} {}: caller carries `{}` role",
188      method,
189      path,
190      super::common::jwt_ops::READ_ONLY_ROLE,
191    );
192    return (
193      StatusCode::FORBIDDEN,
194      Json(ErrorResponse {
195        error: format!(
196          "Token carries the `{}` role; refusing mutating endpoint.",
197          super::common::jwt_ops::READ_ONLY_ROLE
198        ),
199      }),
200    )
201      .into_response();
202  }
203
204  next.run(request).await
205}
206
207#[cfg(test)]
208mod tests;