1use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, header};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response as AxumResponse};
10use serde::de::DeserializeOwned;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::module::HARNESS_API;
15use crate::ports::{Decision, Defer, IdGen, Quota};
16use crate::problem::Problem;
17use crate::scope::Scope;
18use crate::sidecar::{X_HARNESS_API, X_HARNESS_MODULE};
19use crate::usage::Exhausted;
20use tracing::info_span;
21
22pub const X_REQUEST_ID: &str = "x-request-id";
26
27pub const MAX_BODY_BYTES: usize = 64 * 1024;
29
30#[allow(clippy::duration_suboptimal_units)]
33const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
34
35pub fn request_id_is_valid(value: &str) -> bool {
37 (8..=128).contains(&value.len())
38 && value
39 .bytes()
40 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
49pub struct RequestSummary {
50 pub request_id: String,
51 pub method: String,
52 pub route: String,
53 pub module: String,
54 pub status: u16,
55 pub ip_hash: String,
56 pub ua_family: String,
57}
58
59#[derive(Clone)]
62pub(crate) struct ScopeState {
63 pub defer: Arc<dyn Defer>,
64 pub id_gen: Arc<dyn IdGen>,
65 pub module: Option<&'static str>,
72}
73
74pub(crate) async fn scope_layer(
78 axum::extract::State(state): axum::extract::State<ScopeState>,
79 mut request: Request,
80 next: Next,
81) -> AxumResponse {
82 use tracing::Instrument as _;
83 use tracing::field::Empty;
84
85 let incoming = request
86 .headers()
87 .get(X_REQUEST_ID)
88 .and_then(|value| value.to_str().ok())
89 .filter(|value| request_id_is_valid(value));
90 let request_id = match incoming {
91 Some(valid) => valid.to_owned(),
92 None => state.id_gen.ulid(),
93 };
94
95 let method = request.method().as_str().to_owned();
99 let ip_hash = crate::logging::subject_hash(
100 &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
101 );
102 let ua_family = request
103 .headers()
104 .get(header::USER_AGENT)
105 .and_then(|value| value.to_str().ok())
106 .map_or_else(|| "unknown".to_owned(), ua_family_of);
107 let span = info_span!(
108 "request",
109 request_id = %request_id,
110 method = %method,
111 route = Empty,
112 module = Empty,
113 status = Empty,
114 duration_ms = Empty,
115 ip_hash = %ip_hash,
116 ua_family = %ua_family,
117 );
118
119 let scope = Scope {
120 defer: Arc::clone(&state.defer),
121 span: span.clone(),
122 request_id: request_id.clone(),
123 };
124 request.extensions_mut().insert(scope);
125
126 let route = request
127 .extensions()
128 .get::<axum::extract::MatchedPath>()
129 .map(|matched| matched.as_str().to_owned())
130 .unwrap_or_default();
131
132 #[cfg(not(target_arch = "wasm32"))]
140 let started = std::time::Instant::now();
141 let future = next.run(request);
142 let mut response = future.instrument(span.clone()).await;
143
144 if let Ok(value) = HeaderValue::from_str(&request_id) {
145 response.headers_mut().insert(X_REQUEST_ID, value);
146 }
147 if let Ok(value) = HeaderValue::from_str(&HARNESS_API.to_string()) {
156 response.headers_mut().insert(X_HARNESS_API, value);
157 }
158 if let Some(name) = state.module
163 && let Ok(value) = HeaderValue::from_str(name)
164 {
165 response.headers_mut().insert(X_HARNESS_MODULE, value);
166 }
167 let module = route
168 .strip_prefix("/v1/")
169 .and_then(|rest| rest.split('/').next())
170 .unwrap_or_default()
171 .to_owned();
172 let status = response.status().as_u16();
173 span.record("route", route.as_str());
174 span.record("module", module.as_str());
175 span.record("status", status);
176 #[cfg(not(target_arch = "wasm32"))]
177 {
178 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
179 span.record("duration_ms", duration_ms);
180 }
181 response.extensions_mut().insert(RequestSummary {
182 request_id,
183 method,
184 route,
185 module,
186 status,
187 ip_hash,
188 ua_family,
189 });
190 response
191}
192
193fn ua_family_of(user_agent: &str) -> String {
196 let token = user_agent
197 .split(['/', ' ', ';', '('])
198 .next()
199 .unwrap_or_default()
200 .to_lowercase();
201 let truncated: String = token.chars().take(24).collect();
202 if truncated.is_empty() {
203 "unknown".to_owned()
204 } else {
205 truncated
206 }
207}
208
209pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
219 let is_api = request.uri().path().starts_with("/v1/");
220 let mut response = next.run(request).await;
221 if is_api {
222 insert_no_store_headers(response.headers_mut());
223 insert_framing_headers(response.headers_mut());
224 }
225 response
226}
227
228pub(crate) async fn token_response_layer(request: Request, next: Next) -> AxumResponse {
236 let carries_token = request.uri().query().is_some_and(|query| {
237 query
238 .split('&')
239 .any(|pair| pair == "token" || pair.starts_with("token="))
240 });
241 let mut response = next.run(request).await;
242 if carries_token {
243 insert_no_store_headers(response.headers_mut());
244 }
245 response
246}
247
248fn insert_no_store_headers(headers: &mut axum::http::HeaderMap) {
249 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
250 headers.insert(
251 header::X_CONTENT_TYPE_OPTIONS,
252 HeaderValue::from_static("nosniff"),
253 );
254 headers.insert(
255 header::HeaderName::from_static("referrer-policy"),
256 HeaderValue::from_static("no-referrer"),
257 );
258}
259
260fn insert_framing_headers(headers: &mut axum::http::HeaderMap) {
268 headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
272 headers.append(
279 header::CONTENT_SECURITY_POLICY,
280 HeaderValue::from_static("frame-ancestors 'none'"),
281 );
282}
283
284pub struct Json<T>(pub T);
288
289impl<T, S> FromRequest<S> for Json<T>
290where
291 T: DeserializeOwned,
292 S: Send + Sync,
293{
294 type Rejection = Problem;
295
296 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
297 let instance = request
298 .extensions()
299 .get::<Scope>()
300 .map(|scope| scope.request_id.clone());
301 match axum::Json::<T>::from_request(request, state).await {
302 Ok(axum::Json(value)) => Ok(Json(value)),
303 Err(rejection) => {
304 let mut problem = match &rejection {
307 axum::extract::rejection::JsonRejection::BytesRejection(_) => {
308 Problem::request_too_large()
309 }
310 _ => Problem::validation_failed(rejection.body_text()),
311 };
312 if let Some(instance) = instance {
313 problem = problem.instance(&instance);
314 }
315 Err(problem)
316 }
317 }
318 }
319}
320
321impl<T: serde::Serialize> IntoResponse for Json<T> {
322 fn into_response(self) -> AxumResponse {
323 axum::Json(self.0).into_response()
324 }
325}
326
327pub struct Form<T>(pub T);
333
334impl<T, S> FromRequest<S> for Form<T>
335where
336 T: DeserializeOwned,
337 S: Send + Sync,
338{
339 type Rejection = Problem;
340
341 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
342 let instance = request
343 .extensions()
344 .get::<Scope>()
345 .map(|scope| scope.request_id.clone());
346 match axum::Form::<T>::from_request(request, state).await {
347 Ok(axum::Form(value)) => Ok(Form(value)),
348 Err(rejection) => {
349 let mut problem = match &rejection {
350 axum::extract::rejection::FormRejection::BytesRejection(_) => {
351 Problem::request_too_large()
352 }
353 _ => Problem::validation_failed(rejection.body_text()),
354 };
355 if let Some(instance) = instance {
356 problem = problem.instance(&instance);
357 }
358 Err(problem)
359 }
360 }
361 }
362}
363
364pub fn rate_limited(decision: &Decision) -> AxumResponse {
373 let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
374 let mut response = problem.into_response();
375 let pause = decision
376 .retry_after
377 .or_else(|| decision.quota.as_ref().map(|quota| quota.reset));
378 if let Some(pause) = pause
379 && let Ok(value) = HeaderValue::from_str(&delta_seconds(pause))
380 {
381 response.headers_mut().insert(header::RETRY_AFTER, value);
382 }
383 if let Some(quota) = decision.quota.as_ref() {
384 for (name, value) in quota_headers(quota) {
385 if let Ok(value) = HeaderValue::from_str(&value) {
386 response
387 .headers_mut()
388 .insert(header::HeaderName::from_static(name), value);
389 }
390 }
391 }
392 response
393}
394
395pub fn allowance_exhausted(meter: &str, outcome: &Exhausted) -> AxumResponse {
401 let mut response = allowance_exhausted_problem(meter, outcome).into_response();
402 if let Ok(value) = HeaderValue::from_str(&delta_seconds(outcome.retry_after)) {
403 response.headers_mut().insert(header::RETRY_AFTER, value);
404 }
405 response
406}
407
408fn allowance_exhausted_problem(meter: &str, outcome: &Exhausted) -> Problem {
412 Problem::new(&crate::problems::SLUGS.allowance_exhausted)
413 .with_extension("meter", meter)
414 .with_extension("limit", outcome.limit)
415 .with_extension("used", outcome.used)
416 .with_extension("period_end", crate::usage::rfc3339(outcome.period_end))
417}
418
419fn quota_headers(quota: &Quota) -> [(&'static str, String); 3] {
422 [
423 ("ratelimit-limit", quota.limit.to_string()),
424 ("ratelimit-remaining", quota.remaining.to_string()),
425 ("ratelimit-reset", delta_seconds(quota.reset)),
426 ]
427}
428
429fn delta_seconds(pause: Duration) -> String {
432 let millis = u64::try_from(pause.as_millis()).unwrap_or(u64::MAX);
433 millis.div_ceil(1_000).max(1).to_string()
434}
435
436pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
452 use tower_http::cors::{AllowOrigin, CorsLayer};
453 let allowed: Vec<HeaderValue> = origins
454 .iter()
455 .filter_map(|origin| HeaderValue::from_str(origin).ok())
456 .collect();
457 CorsLayer::new()
458 .allow_origin(AllowOrigin::list(allowed))
459 .allow_methods([
460 axum::http::Method::GET,
461 axum::http::Method::POST,
462 axum::http::Method::PUT,
463 axum::http::Method::DELETE,
464 axum::http::Method::OPTIONS,
465 ])
466 .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
467 .max_age(CORS_PREFLIGHT_MAX_AGE)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use axum::http::StatusCode;
474
475 fn header(response: &AxumResponse, name: &str) -> Option<String> {
476 response
477 .headers()
478 .get(name)
479 .and_then(|value| value.to_str().ok().map(str::to_owned))
480 }
481
482 #[test]
483 fn a_pause_becomes_retry_after_and_nothing_else() {
484 let response = rate_limited(&Decision {
485 ok: false,
486 retry_after: Some(Duration::from_secs(7)),
487 quota: None,
488 });
489 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
490 assert_eq!(header(&response, "retry-after").as_deref(), Some("7"));
491 assert!(header(&response, "ratelimit-limit").is_none());
492 }
493
494 #[test]
495 fn a_quota_rides_the_ratelimit_headers() {
496 let response = rate_limited(&Decision {
497 ok: false,
498 retry_after: None,
499 quota: Some(Quota {
500 limit: 30,
501 remaining: 0,
502 reset: Duration::from_secs(43),
503 }),
504 });
505 assert_eq!(header(&response, "retry-after").as_deref(), Some("43"));
506 assert_eq!(header(&response, "ratelimit-limit").as_deref(), Some("30"));
507 assert_eq!(
508 header(&response, "ratelimit-remaining").as_deref(),
509 Some("0")
510 );
511 assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("43"));
512 }
513
514 #[test]
515 fn the_quotas_reset_backfills_a_missing_retry_after() {
516 let response = rate_limited(&Decision {
517 ok: false,
518 retry_after: None,
519 quota: Some(Quota {
520 limit: 5,
521 remaining: 2,
522 reset: Duration::from_millis(1500),
523 }),
524 });
525 assert_eq!(header(&response, "retry-after").as_deref(), Some("2"));
527 assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("2"));
528 }
529
530 #[test]
531 fn an_answerless_decision_is_a_bare_429() {
532 let response = rate_limited(&Decision {
533 ok: false,
534 retry_after: None,
535 quota: None,
536 });
537 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
538 assert!(header(&response, "retry-after").is_none());
539 assert!(header(&response, "ratelimit-limit").is_none());
540 }
541
542 fn exhausted() -> Exhausted {
543 Exhausted {
544 used: 100,
545 limit: 100,
546 period_end: time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("in range"),
547 retry_after: Duration::from_secs(120),
548 }
549 }
550
551 #[test]
552 fn an_exhausted_allowance_is_a_429_with_a_retry_after() {
553 let response = allowance_exhausted("mail_sent", &exhausted());
554 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
555 assert_eq!(header(&response, "retry-after").as_deref(), Some("120"));
556 }
557
558 #[test]
559 fn an_exhausted_allowance_carries_its_meter_limit_used_and_period_end() {
560 let body = allowance_exhausted_problem("mail_sent", &exhausted())
564 .body("https://api.test.example/problems/");
565 assert_eq!(
566 body["type"],
567 "https://api.test.example/problems/usage/allowance-exhausted"
568 );
569 assert_eq!(body["meter"], "mail_sent");
570 assert_eq!(body["limit"], 100);
571 assert_eq!(body["used"], 100);
572 assert_eq!(body["period_end"], "2027-01-15T08:00:00Z");
573 assert_eq!(body["status"], 429);
574 }
575}