Skip to main content

mockforge_http/
management_auth.rs

1//! Token gate for control-plane writes on a hosted mock (#1085).
2//!
3//! A hosted mock serves the customer's API and MockForge's own management
4//! routes on the same public port. When `MOCKFORGE_MANAGEMENT_TOKEN` is set
5//! (the registry sets it on every hosted machine), any request that can
6//! change state on a control-plane route must carry that token. Reads stay
7//! open because the cloud UI fetches the spec, route list, docs and live
8//! stream straight from the browser. Self-hosted servers don't set the
9//! variable, so nothing changes for them.
10
11use 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
20/// Environment variable holding the deployment's management token
21pub const MANAGEMENT_TOKEN_ENV: &str = "MOCKFORGE_MANAGEMENT_TOKEN";
22/// Header that carries the token (`Authorization: Bearer` also works)
23pub const MANAGEMENT_TOKEN_HEADER: &str = "x-mockforge-management-token";
24
25/// Route prefixes served by MockForge itself rather than the user's spec.
26/// A request matches a prefix when its path equals it or continues with `/`.
27const 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
55/// Whether `path` belongs to a MockForge control-plane route
56pub 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
100/// Wrap `app` in the token gate when `MOCKFORGE_MANAGEMENT_TOKEN` is set.
101/// Apply it last, after every router is merged, so no route escapes it.
102pub 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}