manta_server/server/common/
vault.rs

1//! Vault client used by handlers that need backend-specific secrets
2//! (Gitea token for `create_session`, Kubernetes credentials for the
3//! console and log-streaming handlers).
4//!
5//! ## Token-fetch flow
6//!
7//! Three steps every time secrets are needed (no caching):
8//!
9//! 1. [`http_client::auth_oidc_jwt`] POSTs the caller's bearer token
10//!    to `/v1/auth/jwt-manta-<site>/login` and pulls the
11//!    `auth.client_token` out of the response.
12//! 2. [`http_client::get_secret`] GETs an arbitrary path with the
13//!    `X-Vault-Token` header set to the client token and unwraps the
14//!    `.data` field.
15//! 3. The caller (e.g. [`http_client::get_shasta_vcs_token`])
16//!    composes the secret path from `site_name` and reads the
17//!    specific field it needs.
18//!
19//! The flow is non-interactive — there is no human prompt at any
20//! point. If a site lacks Vault entirely, [`crate::server::common::app_context::InfraContext::vault_base_url`]
21//! is `None` and the calling handler returns 501 before ever entering
22//! this module. The Vault client token is short-lived and not cached
23//! across requests; every call re-runs `auth_oidc_jwt`.
24
25/// Thin Vault HTTP client. Authenticates via OIDC/JWT against a
26/// per-site `jwt-manta-<site>` role, then reads K/V v2 secrets under
27/// `manta/data/<...>`.
28pub mod http_client {
29
30  use std::sync::LazyLock;
31
32  use manta_backend_dispatcher::error::Error;
33  use serde_json::{Value, json};
34
35  /// Vault API version prefix.
36  const VAULT_API_PREFIX: &str = "/v1";
37
38  /// Vault KV secret path prefix for manta.
39  const VAULT_SECRET_PATH_PREFIX: &str = "manta/data";
40
41  /// Vault role name used for JWT authentication.
42  const VAULT_ROLE: &str = "manta";
43
44  /// Process-wide `reqwest::Client` reused for every Vault call.
45  ///
46  /// Each call previously built a fresh client (re-doing the TLS
47  /// handshake + connection-pool setup) — every console attach, log
48  /// stream, and SAT-file apply did two such builds. `LazyLock` lets
49  /// us share one `Client` (which is itself an `Arc` internally, so
50  /// it's cheap to share across handlers) and keep keep-alive working.
51  ///
52  /// `Client::builder().build()` only fails on invalid TLS / proxy
53  /// configuration; with all defaults it can't, so the unwrap here is
54  /// safe — see the reqwest::ClientBuilder source for the conditions.
55  static VAULT_HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
56    reqwest::Client::builder()
57      .build()
58      .expect("default reqwest::ClientBuilder build cannot fail")
59  });
60
61  /// Authenticate to Vault using a JWT token and return
62  /// a Vault client token.
63  ///
64  /// Posts the caller's CSM bearer token to
65  /// `/v1/auth/jwt-manta-<site_name>/login` against the `manta` role
66  /// configured at the per-site auth mount.
67  ///
68  /// # Errors
69  ///
70  /// - [`Error::NetError`] when the request itself fails or Vault
71  ///   responds with a non-success status (via
72  ///   `error_for_status()`).
73  /// - [`Error::MissingField`] when the response body omits the
74  ///   server-issued `auth.client_token` field.
75  pub async fn auth_oidc_jwt(
76    vault_base_url: &str,
77    shasta_token: &str,
78    site_name: &str,
79  ) -> Result<String, Error> {
80    let role = VAULT_ROLE;
81
82    let api_url = format!(
83      "{vault_base_url}{VAULT_API_PREFIX}/auth/jwt-manta-{site_name}/login"
84    );
85
86    tracing::debug!("Accessing/login to {}", api_url);
87
88    let request_payload = json!({ "jwt": shasta_token, "role": role });
89
90    let resp = VAULT_HTTP_CLIENT
91      .post(api_url)
92      .header("X-Vault-Request", "true")
93      .json(&request_payload)
94      .send()
95      .await?
96      .error_for_status()?;
97
98    let resp_value = resp.json::<Value>().await?;
99    let client_token = resp_value["auth"]
100      .get("client_token")
101      .and_then(Value::as_str)
102      .ok_or_else(|| {
103        Error::MissingField(
104          "Vault auth response missing 'client_token' field".to_string(),
105        )
106      })?;
107    Ok(client_token.to_string())
108  }
109
110  /// Get a secret from Vault's KV store at `secret_path`.
111  ///
112  /// `secret_path` is concatenated onto `vault_base_url` verbatim;
113  /// callers are responsible for the leading `/v1/...` prefix. The
114  /// returned `Value` is the `.data` field of the Vault response
115  /// (the K/V envelope itself is stripped here).
116  ///
117  /// # Errors
118  ///
119  /// [`Error::NetError`] when the HTTP request fails or Vault
120  /// responds with a non-success status.
121  pub async fn get_secret(
122    vault_auth_token: &str,
123    vault_base_url: &str,
124    secret_path: &str,
125  ) -> Result<Value, Error> {
126    let api_url = vault_base_url.to_owned() + secret_path;
127
128    tracing::debug!("Vault url to fetch VCS secrets is '{}'", api_url);
129
130    let resp = VAULT_HTTP_CLIENT
131      .get(api_url)
132      .header("X-Vault-Token", vault_auth_token)
133      .send()
134      .await?
135      .error_for_status()?;
136
137    let secret_value: Value = resp.json().await?;
138    Ok(secret_value["data"].clone())
139  }
140
141  /// Retrieve the Gitea VCS token from Vault.
142  ///
143  /// Reads `manta/data/<site_name>/vcs.data.token` after exchanging
144  /// the caller's CSM bearer for a Vault client token via
145  /// [`auth_oidc_jwt`]. Used by `service::session::create_session`
146  /// and SAT-file rendering paths that need to clone a CFS layer's
147  /// Gitea repository under the caller's identity.
148  ///
149  /// # Errors
150  ///
151  /// Any error produced by [`auth_oidc_jwt`] or [`get_secret`], plus
152  /// [`Error::MissingField`] when the K/V secret has no `token`
153  /// field.
154  pub async fn get_shasta_vcs_token(
155    shasta_token: &str,
156    vault_base_url: &str,
157    site_name: &str,
158  ) -> Result<String, Error> {
159    let vault_token =
160      auth_oidc_jwt(vault_base_url, shasta_token, site_name).await?;
161
162    let vault_secret_path = format!("{VAULT_SECRET_PATH_PREFIX}/{site_name}");
163
164    let vault_secret = get_secret(
165      &vault_token,
166      vault_base_url,
167      &format!("{VAULT_API_PREFIX}/{vault_secret_path}/vcs"),
168    )
169    .await?;
170
171    let vcs_token = vault_secret["data"]
172      .get("token")
173      .and_then(Value::as_str)
174      .ok_or_else(|| {
175      Error::MissingField(
176        "Vault secret response missing 'token' field".to_string(),
177      )
178    })?;
179
180    Ok(vcs_token.to_string())
181  }
182}