Skip to main content

hyperi_rustlib/secrets/
vault.rs

1// Project:   hyperi-rustlib
2// File:      src/secrets/vault.rs
3// Purpose:   OpenBao/Vault secret provider
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! OpenBao/Vault secret provider using vaultrs.
10//!
11//! Supports multiple authentication methods:
12//! - Token authentication
13//! - AppRole authentication
14//! - Kubernetes authentication
15
16use serde::{Deserialize, Serialize};
17use tracing::debug;
18use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
19use vaultrs::kv2;
20
21use super::error::{SecretsError, SecretsResult};
22use super::provider::SecretProvider;
23use super::types::{SecretMetadata, SecretValue};
24use crate::sensitive::SensitiveString;
25
26/// OpenBao/Vault connection configuration.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct OpenBaoConfig {
29    /// Vault address (e.g., `https://vault.example.com:8200`).
30    pub address: String,
31
32    /// Authentication method.
33    pub auth: OpenBaoAuth,
34
35    /// Namespace (for Vault Enterprise).
36    #[serde(default)]
37    pub namespace: Option<String>,
38
39    /// TLS CA certificate path for Vault server.
40    #[serde(default)]
41    pub ca_cert: Option<String>,
42
43    /// Skip TLS verification (not recommended for production).
44    #[serde(default)]
45    pub skip_verify: bool,
46}
47
48/// OpenBao/Vault authentication method.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(tag = "method", rename_all = "snake_case")]
51pub enum OpenBaoAuth {
52    /// Token authentication.
53    Token {
54        /// Vault token. Redacted in `Debug`/serialised output; `.expose()`
55        /// only at the auth call site.
56        token: SensitiveString,
57    },
58
59    /// AppRole authentication.
60    AppRole {
61        /// Role ID. An identifier (like a username), not a credential.
62        role_id: String,
63        /// Secret ID. The AppRole credential -- redacted in
64        /// `Debug`/serialised output; `.expose()` only at the auth call site.
65        secret_id: SensitiveString,
66        /// Mount path (default: "approle").
67        #[serde(default = "default_approle_mount")]
68        mount: String,
69    },
70
71    /// Kubernetes authentication.
72    Kubernetes {
73        /// Role name.
74        role: String,
75        /// Path to service account token.
76        #[serde(default = "default_k8s_token_path")]
77        token_path: String,
78        /// Mount path (default: "kubernetes").
79        #[serde(default = "default_k8s_mount")]
80        mount: String,
81    },
82}
83
84fn default_approle_mount() -> String {
85    "approle".to_string()
86}
87
88fn default_k8s_token_path() -> String {
89    "/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
90}
91
92fn default_k8s_mount() -> String {
93    "kubernetes".to_string()
94}
95
96impl OpenBaoConfig {
97    /// Load configuration from environment variables.
98    ///
99    /// Uses standard `VAULT_*` environment variables with `OPENBAO_*` and `BAO_*`
100    /// as legacy fallbacks (with deprecation warnings).
101    ///
102    /// ## Environment Variables
103    ///
104    /// - `VAULT_ADDR` - Vault/OpenBao server address
105    /// - `VAULT_TOKEN` - Authentication token (for token auth)
106    /// - `VAULT_ROLE_ID` + `VAULT_SECRET_ID` - AppRole authentication
107    /// - `VAULT_K8S_ROLE` - Kubernetes authentication role
108    /// - `VAULT_NAMESPACE` - Vault namespace (Enterprise)
109    /// - `VAULT_CACERT` - Path to CA certificate
110    /// - `VAULT_SKIP_VERIFY` - Skip TLS verification
111    ///
112    /// ## Authentication Priority
113    ///
114    /// 1. If `VAULT_TOKEN` is set, uses token authentication
115    /// 2. If `VAULT_ROLE_ID` and `VAULT_SECRET_ID` are set, uses AppRole
116    /// 3. If `VAULT_K8S_ROLE` is set, uses Kubernetes authentication
117    /// 4. Otherwise, returns an error
118    ///
119    /// # Errors
120    ///
121    /// Returns `None` if `VAULT_ADDR` is not set or no authentication method
122    /// can be determined.
123    #[cfg(feature = "config")]
124    #[must_use]
125    pub fn from_env() -> Option<Self> {
126        use crate::config::env_compat::vault;
127
128        let address = vault::addr().get()?;
129
130        // Determine authentication method
131        let auth = if let Some(token) = vault::token().get() {
132            OpenBaoAuth::Token {
133                token: token.into(),
134            }
135        } else if let (Some(role_id), Some(secret_id)) = (
136            vault::approle_role_id().get(),
137            vault::approle_secret_id().get(),
138        ) {
139            OpenBaoAuth::AppRole {
140                role_id,
141                secret_id: secret_id.into(),
142                mount: default_approle_mount(),
143            }
144        } else if let Some(role) = vault::k8s_role().get() {
145            OpenBaoAuth::Kubernetes {
146                role,
147                token_path: default_k8s_token_path(),
148                mount: default_k8s_mount(),
149            }
150        } else {
151            // No authentication method configured
152            return None;
153        };
154
155        Some(Self {
156            address,
157            auth,
158            namespace: vault::namespace().get(),
159            ca_cert: vault::ca_cert().get(),
160            skip_verify: vault::skip_verify().get_bool().unwrap_or(false),
161        })
162    }
163
164    /// Create a configuration for token authentication.
165    #[must_use]
166    pub fn with_token(address: &str, token: &str) -> Self {
167        Self {
168            address: address.to_string(),
169            auth: OpenBaoAuth::Token {
170                token: token.into(),
171            },
172            namespace: None,
173            ca_cert: None,
174            skip_verify: false,
175        }
176    }
177
178    /// Create a configuration for AppRole authentication.
179    #[must_use]
180    pub fn with_approle(address: &str, role_id: &str, secret_id: &str) -> Self {
181        Self {
182            address: address.to_string(),
183            auth: OpenBaoAuth::AppRole {
184                role_id: role_id.to_string(),
185                secret_id: secret_id.into(),
186                mount: default_approle_mount(),
187            },
188            namespace: None,
189            ca_cert: None,
190            skip_verify: false,
191        }
192    }
193
194    /// Set the namespace (for Vault Enterprise).
195    #[must_use]
196    pub fn with_namespace(mut self, namespace: &str) -> Self {
197        self.namespace = Some(namespace.to_string());
198        self
199    }
200
201    /// Set the CA certificate path.
202    #[must_use]
203    pub fn with_ca_cert(mut self, path: &str) -> Self {
204        self.ca_cert = Some(path.to_string());
205        self
206    }
207
208    /// Enable TLS skip verification (dev/test only -- rejected in production
209    /// by [`validate`](Self::validate)).
210    #[must_use]
211    pub fn with_skip_verify(mut self) -> Self {
212        self.skip_verify = true;
213        self
214    }
215
216    /// Validate the Vault config against the deployment profile.
217    ///
218    /// `skip_verify` disables TLS certificate verification, which exposes the
219    /// connection to MITM. It is permitted only in dev/test; under a
220    /// production profile this returns an error. Call at startup with
221    /// [`crate::env::is_production`].
222    ///
223    /// NOTE: `skip_verify` is slated for removal at GA -- private-CA trust via
224    /// `ca_cert` (the unified TLS module) is the supported path.
225    ///
226    /// # Errors
227    ///
228    /// Returns `Err` when `is_production` and `skip_verify` is set.
229    pub fn validate(&self, is_production: bool) -> Result<(), String> {
230        if is_production && self.skip_verify {
231            return Err(
232                "vault: skip_verify (TLS verification disabled) is not permitted in \
233                 production -- configure ca_cert for private-CA trust instead"
234                    .to_string(),
235            );
236        }
237        Ok(())
238    }
239}
240
241/// OpenBao/Vault secret provider.
242pub struct OpenBaoProvider {
243    config: OpenBaoConfig,
244}
245
246impl OpenBaoProvider {
247    /// Create a new OpenBao provider.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if client initialization fails.
252    pub fn new(config: &OpenBaoConfig) -> SecretsResult<Self> {
253        // Enforce the production guardrail at the construction boundary -- a
254        // shared library cannot rely on every app remembering to call
255        // validate() at startup (Codex review 2026-06-03).
256        config
257            .validate(crate::env::is_production())
258            .map_err(SecretsError::ConfigError)?;
259        Ok(Self {
260            config: config.clone(),
261        })
262    }
263
264    /// Get an authenticated Vault client.
265    ///
266    /// Creates a new client for each request since `VaultClient` is not Clone.
267    /// The underlying HTTP client uses connection pooling so this is efficient.
268    async fn get_client(&self) -> SecretsResult<VaultClient> {
269        self.create_client().await
270    }
271
272    /// Create and authenticate a new Vault client.
273    async fn create_client(&self) -> SecretsResult<VaultClient> {
274        let mut settings = VaultClientSettingsBuilder::default();
275        settings.address(&self.config.address);
276
277        if let Some(ref ns) = self.config.namespace {
278            settings.namespace(Some(ns.clone()));
279        }
280
281        // Private-CA trust + verification (finding 9: these were previously
282        // ignored). vaultrs owns its reqwest client, so we route through its
283        // native settings rather than the unified `tls` module: a PEM CA file
284        // for private-CA deployments, and TLS verification on unless the
285        // dev-only `skip_verify` is set (rejected in prod by `validate`).
286        if let Some(ref ca) = self.config.ca_cert {
287            settings.ca_certs(vec![ca.clone()]);
288        }
289        settings.verify(!self.config.skip_verify);
290
291        let settings = settings.build().map_err(|e| {
292            SecretsError::ConfigError(format!("failed to build Vault client settings: {e}"))
293        })?;
294
295        let mut client = VaultClient::new(settings).map_err(|e| {
296            SecretsError::ProviderError(format!("failed to create Vault client: {e}"))
297        })?;
298
299        // Authenticate based on method
300        match &self.config.auth {
301            OpenBaoAuth::Token { token } => {
302                client.set_token(token.expose());
303            }
304            OpenBaoAuth::AppRole {
305                role_id,
306                secret_id,
307                mount,
308            } => {
309                self.auth_approle(&mut client, role_id, secret_id.expose(), mount)
310                    .await?;
311            }
312            OpenBaoAuth::Kubernetes {
313                role,
314                token_path,
315                mount,
316            } => {
317                self.auth_kubernetes(&mut client, role, token_path, mount)
318                    .await?;
319            }
320        }
321
322        Ok(client)
323    }
324
325    /// Authenticate using AppRole.
326    async fn auth_approle(
327        &self,
328        client: &mut VaultClient,
329        role_id: &str,
330        secret_id: &str,
331        mount: &str,
332    ) -> SecretsResult<()> {
333        let auth_info = vaultrs::auth::approle::login(client, mount, role_id, secret_id)
334            .await
335            .map_err(|e| SecretsError::AuthError(format!("AppRole login failed: {e}")))?;
336
337        client.set_token(&auth_info.client_token);
338        debug!("AppRole authentication successful");
339        Ok(())
340    }
341
342    /// Authenticate using Kubernetes service account.
343    async fn auth_kubernetes(
344        &self,
345        client: &mut VaultClient,
346        role: &str,
347        token_path: &str,
348        mount: &str,
349    ) -> SecretsResult<()> {
350        let jwt = tokio::fs::read_to_string(token_path).await.map_err(|e| {
351            SecretsError::AuthError(format!(
352                "failed to read K8s service account token from {token_path}: {e}"
353            ))
354        })?;
355
356        let auth_info = vaultrs::auth::kubernetes::login(client, mount, role, jwt.trim())
357            .await
358            .map_err(|e| SecretsError::AuthError(format!("Kubernetes login failed: {e}")))?;
359
360        client.set_token(&auth_info.client_token);
361        debug!("Kubernetes authentication successful");
362        Ok(())
363    }
364
365    /// Get a secret from Vault KV v2.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the secret cannot be fetched.
370    pub async fn get(&self, path: &str, key: &str) -> SecretsResult<SecretValue> {
371        let client = self.get_client().await?;
372
373        // Parse path to extract mount and secret path
374        // Expected format: "secret/data/myapp/tls" or "myapp/tls" (assumes "secret" mount)
375        let (mount, secret_path) = Self::parse_path(path);
376
377        // Read the secret
378        let secret: std::collections::HashMap<String, String> =
379            kv2::read(&client, &mount, &secret_path)
380                .await
381                .map_err(|e| {
382                    // Check for auth errors (token expired)
383                    if e.to_string().contains("403") || e.to_string().contains("permission denied")
384                    {
385                        SecretsError::AuthError("Vault token expired or invalid".into())
386                    } else {
387                        SecretsError::ProviderError(format!("failed to read secret {path}: {e}"))
388                    }
389                })?;
390
391        // Extract the requested key
392        let value = secret.get(key).ok_or_else(|| {
393            SecretsError::NotFound(format!("key '{key}' not found in secret '{path}'"))
394        })?;
395
396        let metadata = SecretMetadata {
397            version: None, // KV v2 version would require additional API call
398            source_path: Some(path.to_string()),
399            provider: Some("openbao".into()),
400        };
401
402        Ok(SecretValue::with_metadata(
403            value.as_bytes().to_vec(),
404            metadata,
405        ))
406    }
407
408    /// Parse a Vault path into mount and secret path.
409    ///
410    /// Handles formats:
411    /// - "secret/data/myapp/tls" -> ("secret", "myapp/tls")
412    /// - "myapp/tls" -> ("secret", "myapp/tls") (default mount)
413    fn parse_path(path: &str) -> (String, String) {
414        // Check for KV v2 "data" in path
415        if let Some(rest) = path.strip_prefix("secret/data/") {
416            return ("secret".into(), rest.into());
417        }
418
419        // Check for custom mount with "data" segment
420        let parts: Vec<&str> = path.splitn(3, '/').collect();
421        if parts.len() >= 3 && parts[1] == "data" {
422            return (parts[0].into(), parts[2..].join("/"));
423        }
424
425        // Default to "secret" mount
426        ("secret".into(), path.into())
427    }
428}
429
430impl SecretProvider for OpenBaoProvider {
431    async fn get(&self, path: &str, key: Option<&str>) -> SecretsResult<SecretValue> {
432        let key = key.ok_or_else(|| {
433            SecretsError::ConfigError("key is required for OpenBao secrets".into())
434        })?;
435        self.get(path, key).await
436    }
437
438    async fn health_check(&self) -> SecretsResult<()> {
439        let client = self.get_client().await?;
440
441        // Check sys/health endpoint
442        vaultrs::sys::health(&client)
443            .await
444            .map_err(|e| SecretsError::ProviderError(format!("Vault health check failed: {e}")))?;
445
446        Ok(())
447    }
448
449    fn name(&self) -> &'static str {
450        "openbao"
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_parse_path_with_mount() {
460        let (mount, path) = OpenBaoProvider::parse_path("secret/data/myapp/tls");
461        assert_eq!(mount, "secret");
462        assert_eq!(path, "myapp/tls");
463    }
464
465    #[test]
466    fn test_parse_path_custom_mount() {
467        let (mount, path) = OpenBaoProvider::parse_path("kv/data/myapp/creds");
468        assert_eq!(mount, "kv");
469        assert_eq!(path, "myapp/creds");
470    }
471
472    #[test]
473    fn test_parse_path_default_mount() {
474        let (mount, path) = OpenBaoProvider::parse_path("myapp/tls");
475        assert_eq!(mount, "secret");
476        assert_eq!(path, "myapp/tls");
477    }
478
479    #[test]
480    fn test_openbao_auth_token_serialization() {
481        let auth = OpenBaoAuth::Token {
482            token: "test-token".into(),
483        };
484        let json = serde_json::to_string(&auth).unwrap();
485        assert!(json.contains("\"method\":\"token\""));
486    }
487
488    #[test]
489    fn test_openbao_auth_approle_serialization() {
490        let auth = OpenBaoAuth::AppRole {
491            role_id: "role123".into(),
492            secret_id: "secret456".into(),
493            mount: "approle".into(),
494        };
495        let json = serde_json::to_string(&auth).unwrap();
496        assert!(json.contains("\"method\":\"app_role\""));
497        assert!(json.contains("role_id"));
498    }
499
500    #[test]
501    fn test_openbao_auth_redacts_secrets() {
502        // Token + secret_id values must never appear in Debug or serialised
503        // output -- they are SensitiveString. role_id (an identifier) may.
504        let token_auth = OpenBaoAuth::Token {
505            token: "super-secret-token".into(),
506        };
507        let dbg = format!("{token_auth:?}");
508        let json = serde_json::to_string(&token_auth).unwrap();
509        assert!(!dbg.contains("super-secret-token"), "token leaked in Debug");
510        assert!(!json.contains("super-secret-token"), "token leaked in JSON");
511
512        let approle = OpenBaoAuth::AppRole {
513            role_id: "role-abc".into(),
514            secret_id: "super-secret-id".into(),
515            mount: "approle".into(),
516        };
517        let dbg = format!("{approle:?}");
518        let json = serde_json::to_string(&approle).unwrap();
519        assert!(
520            !dbg.contains("super-secret-id"),
521            "secret_id leaked in Debug"
522        );
523        assert!(
524            !json.contains("super-secret-id"),
525            "secret_id leaked in JSON"
526        );
527        // role_id is a non-secret identifier and is allowed through.
528        assert!(json.contains("role-abc"));
529    }
530
531    #[test]
532    fn validate_rejects_skip_verify_in_production() {
533        let insecure = OpenBaoConfig::with_token("https://vault:8200", "t").with_skip_verify();
534        assert!(insecure.skip_verify);
535        assert!(insecure.validate(false).is_ok(), "dev allows skip_verify");
536        assert!(
537            insecure.validate(true).is_err(),
538            "production must reject skip_verify"
539        );
540
541        let secure = OpenBaoConfig::with_token("https://vault:8200", "t");
542        assert!(!secure.skip_verify);
543        assert!(secure.validate(true).is_ok());
544    }
545
546    #[test]
547    fn test_openbao_config_serialization() {
548        let config = OpenBaoConfig {
549            address: "https://vault.example.com:8200".into(),
550            auth: OpenBaoAuth::Token {
551                token: "test".into(),
552            },
553            namespace: Some("myorg".into()),
554            ca_cert: None,
555            skip_verify: false,
556        };
557        let json = serde_json::to_string(&config).unwrap();
558        assert!(json.contains("vault.example.com"));
559    }
560}