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}