mockforge_http/
management_auth.rs1use axum::body::Body;
12use axum::extract::State;
13use axum::http::{header, Method, Request, StatusCode};
14use axum::middleware::Next;
15use axum::response::{IntoResponse, Response};
16use axum::{Json, Router};
17use std::sync::Arc;
18use subtle::ConstantTimeEq;
19
20pub const MANAGEMENT_TOKEN_ENV: &str = "MOCKFORGE_MANAGEMENT_TOKEN";
22pub const MANAGEMENT_TOKEN_HEADER: &str = "x-mockforge-management-token";
24
25const CONTROL_PLANE_PREFIXES: &[&str] = &[
28 "/__mockforge",
29 "/api/chaos",
30 "/api/recorder",
31 "/api/performance",
32 "/api/world-state",
33 "/api/verification",
34 "/api/v1/ab-tests",
35 "/api/v1/auth",
36 "/api/v1/behavioral-cloning",
37 "/api/v1/change-management",
38 "/api/v1/consistency",
39 "/api/v1/consumers",
40 "/api/v1/contract-health",
41 "/api/v1/contracts",
42 "/api/v1/drift",
43 "/api/v1/forecasts",
44 "/api/v1/pipelines",
45 "/api/v1/pr",
46 "/api/v1/scenario-studio",
47 "/api/v1/security",
48 "/api/v1/semantic-drift",
49 "/api/v1/snapshots",
50 "/api/v1/threats",
51 "/api/v1/workspace",
52 "/api/v1/xray",
53];
54
55pub fn is_control_plane(path: &str) -> bool {
57 CONTROL_PLANE_PREFIXES.iter().any(|prefix| {
58 path.strip_prefix(prefix)
59 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
60 })
61}
62
63fn is_read(method: &Method) -> bool {
64 matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
65}
66
67fn presented_token(req: &Request<Body>) -> Option<&str> {
68 let headers = req.headers();
69 headers.get(MANAGEMENT_TOKEN_HEADER).and_then(|v| v.to_str().ok()).or_else(|| {
70 headers
71 .get(header::AUTHORIZATION)
72 .and_then(|v| v.to_str().ok())
73 .and_then(|v| v.strip_prefix("Bearer "))
74 })
75}
76
77async fn require_management_token(
78 State(expected): State<Arc<str>>,
79 req: Request<Body>,
80 next: Next,
81) -> Response {
82 if is_read(req.method()) || !is_control_plane(req.uri().path()) {
83 return next.run(req).await;
84 }
85 let authorized = presented_token(&req)
86 .is_some_and(|token| bool::from(token.as_bytes().ct_eq(expected.as_bytes())));
87 if authorized {
88 return next.run(req).await;
89 }
90 (
91 StatusCode::UNAUTHORIZED,
92 Json(serde_json::json!({
93 "error": "This hosted mock's management API requires its management token \
94 (X-MockForge-Management-Token). Find it on the Hosted Mocks page."
95 })),
96 )
97 .into_response()
98}
99
100pub fn gate_control_plane_writes(app: Router) -> Router {
103 match std::env::var(MANAGEMENT_TOKEN_ENV) {
104 Ok(token) if !token.is_empty() => gate_with_token(app, token),
105 _ => app,
106 }
107}
108
109fn gate_with_token(app: Router, token: String) -> Router {
110 let expected: Arc<str> = Arc::from(token);
111 app.layer(axum::middleware::from_fn_with_state(expected, require_management_token))
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use axum::routing::get;
118 use tower::ServiceExt;
119
120 fn app() -> Router {
121 let ok = || async { "ok" };
122 gate_with_token(
123 Router::new()
124 .route("/__mockforge/api/mocks", get(ok).post(ok))
125 .route("/api/chaos/config", get(ok).post(ok))
126 .route("/api/chaosmonkey", get(ok).post(ok))
127 .route("/api/v1/users", get(ok).post(ok)),
128 "secret-token".to_string(),
129 )
130 }
131
132 async fn status(method: Method, path: &str, header: Option<(&str, &str)>) -> StatusCode {
133 let mut req = Request::builder().method(method).uri(path);
134 if let Some((name, value)) = header {
135 req = req.header(name, value);
136 }
137 app().oneshot(req.body(Body::empty()).unwrap()).await.unwrap().status()
138 }
139
140 #[test]
141 fn control_plane_prefixes_match_whole_segments() {
142 assert!(is_control_plane("/__mockforge"));
143 assert!(is_control_plane("/__mockforge/time-travel/advance"));
144 assert!(is_control_plane("/api/v1/pr/generate"));
145 assert!(!is_control_plane("/api/chaosmonkey"));
146 assert!(!is_control_plane("/api/v1/prices"));
147 assert!(!is_control_plane("/api/v1/users"));
148 }
149
150 #[tokio::test]
151 async fn writes_need_the_token_and_reads_do_not() {
152 assert_eq!(
153 status(Method::POST, "/__mockforge/api/mocks", None).await,
154 StatusCode::UNAUTHORIZED
155 );
156 assert_eq!(status(Method::POST, "/api/chaos/config", None).await, StatusCode::UNAUTHORIZED);
157 assert_eq!(
158 status(
159 Method::POST,
160 "/__mockforge/api/mocks",
161 Some((MANAGEMENT_TOKEN_HEADER, "wrong"))
162 )
163 .await,
164 StatusCode::UNAUTHORIZED
165 );
166 assert_eq!(
167 status(
168 Method::POST,
169 "/__mockforge/api/mocks",
170 Some((MANAGEMENT_TOKEN_HEADER, "secret-token"))
171 )
172 .await,
173 StatusCode::OK
174 );
175 assert_eq!(
176 status(
177 Method::POST,
178 "/api/chaos/config",
179 Some(("authorization", "Bearer secret-token"))
180 )
181 .await,
182 StatusCode::OK
183 );
184 assert_eq!(status(Method::GET, "/__mockforge/api/mocks", None).await, StatusCode::OK);
185 }
186
187 #[tokio::test]
188 async fn the_customers_own_routes_are_untouched() {
189 assert_eq!(status(Method::POST, "/api/v1/users", None).await, StatusCode::OK);
190 assert_eq!(status(Method::POST, "/api/chaosmonkey", None).await, StatusCode::OK);
191 }
192}