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:   FSL-1.1-ALv2
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};
24
25/// OpenBao/Vault connection configuration.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct OpenBaoConfig {
28    /// Vault address (e.g., `https://vault.example.com:8200`).
29    pub address: String,
30
31    /// Authentication method.
32    pub auth: OpenBaoAuth,
33
34    /// Namespace (for Vault Enterprise).
35    #[serde(default)]
36    pub namespace: Option<String>,
37
38    /// TLS CA certificate path for Vault server.
39    #[serde(default)]
40    pub ca_cert: Option<String>,
41
42    /// Skip TLS verification (not recommended for production).
43    #[serde(default)]
44    pub skip_verify: bool,
45}
46
47/// OpenBao/Vault authentication method.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "method", rename_all = "snake_case")]
50pub enum OpenBaoAuth {
51    /// Token authentication.
52    Token {
53        /// Vault token.
54        token: String,
55    },
56
57    /// AppRole authentication.
58    AppRole {
59        /// Role ID.
60        role_id: String,
61        /// Secret ID.
62        secret_id: String,
63        /// Mount path (default: "approle").
64        #[serde(default = "default_approle_mount")]
65        mount: String,
66    },
67
68    /// Kubernetes authentication.
69    Kubernetes {
70        /// Role name.
71        role: String,
72        /// Path to service account token.
73        #[serde(default = "default_k8s_token_path")]
74        token_path: String,
75        /// Mount path (default: "kubernetes").
76        #[serde(default = "default_k8s_mount")]
77        mount: String,
78    },
79}
80
81fn default_approle_mount() -> String {
82    "approle".to_string()
83}
84
85fn default_k8s_token_path() -> String {
86    "/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
87}
88
89fn default_k8s_mount() -> String {
90    "kubernetes".to_string()
91}
92
93impl OpenBaoConfig {
94    /// Load configuration from environment variables.
95    ///
96    /// Uses standard `VAULT_*` environment variables with `OPENBAO_*` and `BAO_*`
97    /// as legacy fallbacks (with deprecation warnings).
98    ///
99    /// ## Environment Variables
100    ///
101    /// - `VAULT_ADDR` - Vault/OpenBao server address
102    /// - `VAULT_TOKEN` - Authentication token (for token auth)
103    /// - `VAULT_ROLE_ID` + `VAULT_SECRET_ID` - AppRole authentication
104    /// - `VAULT_K8S_ROLE` - Kubernetes authentication role
105    /// - `VAULT_NAMESPACE` - Vault namespace (Enterprise)
106    /// - `VAULT_CACERT` - Path to CA certificate
107    /// - `VAULT_SKIP_VERIFY` - Skip TLS verification
108    ///
109    /// ## Authentication Priority
110    ///
111    /// 1. If `VAULT_TOKEN` is set, uses token authentication
112    /// 2. If `VAULT_ROLE_ID` and `VAULT_SECRET_ID` are set, uses AppRole
113    /// 3. If `VAULT_K8S_ROLE` is set, uses Kubernetes authentication
114    /// 4. Otherwise, returns an error
115    ///
116    /// # Errors
117    ///
118    /// Returns `None` if `VAULT_ADDR` is not set or no authentication method
119    /// can be determined.
120    #[cfg(feature = "config")]
121    #[must_use]
122    pub fn from_env() -> Option<Self> {
123        use crate::config::env_compat::vault;
124
125        let address = vault::addr().get()?;
126
127        // Determine authentication method
128        let auth = if let Some(token) = vault::token().get() {
129            OpenBaoAuth::Token { token }
130        } else if let (Some(role_id), Some(secret_id)) = (
131            vault::approle_role_id().get(),
132            vault::approle_secret_id().get(),
133        ) {
134            OpenBaoAuth::AppRole {
135                role_id,
136                secret_id,
137                mount: default_approle_mount(),
138            }
139        } else if let Some(role) = vault::k8s_role().get() {
140            OpenBaoAuth::Kubernetes {
141                role,
142                token_path: default_k8s_token_path(),
143                mount: default_k8s_mount(),
144            }
145        } else {
146            // No authentication method configured
147            return None;
148        };
149
150        Some(Self {
151            address,
152            auth,
153            namespace: vault::namespace().get(),
154            ca_cert: vault::ca_cert().get(),
155            skip_verify: vault::skip_verify().get_bool().unwrap_or(false),
156        })
157    }
158
159    /// Create a configuration for token authentication.
160    #[must_use]
161    pub fn with_token(address: &str, token: &str) -> Self {
162        Self {
163            address: address.to_string(),
164            auth: OpenBaoAuth::Token {
165                token: token.to_string(),
166            },
167            namespace: None,
168            ca_cert: None,
169            skip_verify: false,
170        }
171    }
172
173    /// Create a configuration for AppRole authentication.
174    #[must_use]
175    pub fn with_approle(address: &str, role_id: &str, secret_id: &str) -> Self {
176        Self {
177            address: address.to_string(),
178            auth: OpenBaoAuth::AppRole {
179                role_id: role_id.to_string(),
180                secret_id: secret_id.to_string(),
181                mount: default_approle_mount(),
182            },
183            namespace: None,
184            ca_cert: None,
185            skip_verify: false,
186        }
187    }
188
189    /// Set the namespace (for Vault Enterprise).
190    #[must_use]
191    pub fn with_namespace(mut self, namespace: &str) -> Self {
192        self.namespace = Some(namespace.to_string());
193        self
194    }
195
196    /// Set the CA certificate path.
197    #[must_use]
198    pub fn with_ca_cert(mut self, path: &str) -> Self {
199        self.ca_cert = Some(path.to_string());
200        self
201    }
202
203    /// Enable TLS skip verification (not recommended for production).
204    #[must_use]
205    pub fn with_skip_verify(mut self) -> Self {
206        self.skip_verify = true;
207        self
208    }
209}
210
211/// OpenBao/Vault secret provider.
212pub struct OpenBaoProvider {
213    config: OpenBaoConfig,
214}
215
216impl OpenBaoProvider {
217    /// Create a new OpenBao provider.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if client initialization fails.
222    pub fn new(config: &OpenBaoConfig) -> SecretsResult<Self> {
223        Ok(Self {
224            config: config.clone(),
225        })
226    }
227
228    /// Get an authenticated Vault client.
229    ///
230    /// Creates a new client for each request since `VaultClient` is not Clone.
231    /// The underlying HTTP client uses connection pooling so this is efficient.
232    async fn get_client(&self) -> SecretsResult<VaultClient> {
233        self.create_client().await
234    }
235
236    /// Create and authenticate a new Vault client.
237    async fn create_client(&self) -> SecretsResult<VaultClient> {
238        let mut settings = VaultClientSettingsBuilder::default();
239        settings.address(&self.config.address);
240
241        if let Some(ref ns) = self.config.namespace {
242            settings.namespace(Some(ns.clone()));
243        }
244
245        // Note: vaultrs handles TLS configuration via the address URL scheme
246        // For custom CA certs, users should configure system trust store or use VAULT_CACERT env var
247
248        let settings = settings.build().map_err(|e| {
249            SecretsError::ConfigError(format!("failed to build Vault client settings: {e}"))
250        })?;
251
252        let mut client = VaultClient::new(settings).map_err(|e| {
253            SecretsError::ProviderError(format!("failed to create Vault client: {e}"))
254        })?;
255
256        // Authenticate based on method
257        match &self.config.auth {
258            OpenBaoAuth::Token { token } => {
259                client.set_token(token);
260            }
261            OpenBaoAuth::AppRole {
262                role_id,
263                secret_id,
264                mount,
265            } => {
266                self.auth_approle(&mut client, role_id, secret_id, mount)
267                    .await?;
268            }
269            OpenBaoAuth::Kubernetes {
270                role,
271                token_path,
272                mount,
273            } => {
274                self.auth_kubernetes(&mut client, role, token_path, mount)
275                    .await?;
276            }
277        }
278
279        Ok(client)
280    }
281
282    /// Authenticate using AppRole.
283    async fn auth_approle(
284        &self,
285        client: &mut VaultClient,
286        role_id: &str,
287        secret_id: &str,
288        mount: &str,
289    ) -> SecretsResult<()> {
290        let auth_info = vaultrs::auth::approle::login(client, mount, role_id, secret_id)
291            .await
292            .map_err(|e| SecretsError::AuthError(format!("AppRole login failed: {e}")))?;
293
294        client.set_token(&auth_info.client_token);
295        debug!("AppRole authentication successful");
296        Ok(())
297    }
298
299    /// Authenticate using Kubernetes service account.
300    async fn auth_kubernetes(
301        &self,
302        client: &mut VaultClient,
303        role: &str,
304        token_path: &str,
305        mount: &str,
306    ) -> SecretsResult<()> {
307        let jwt = tokio::fs::read_to_string(token_path).await.map_err(|e| {
308            SecretsError::AuthError(format!(
309                "failed to read K8s service account token from {token_path}: {e}"
310            ))
311        })?;
312
313        let auth_info = vaultrs::auth::kubernetes::login(client, mount, role, jwt.trim())
314            .await
315            .map_err(|e| SecretsError::AuthError(format!("Kubernetes login failed: {e}")))?;
316
317        client.set_token(&auth_info.client_token);
318        debug!("Kubernetes authentication successful");
319        Ok(())
320    }
321
322    /// Get a secret from Vault KV v2.
323    ///
324    /// # Errors
325    ///
326    /// Returns an error if the secret cannot be fetched.
327    pub async fn get(&self, path: &str, key: &str) -> SecretsResult<SecretValue> {
328        let client = self.get_client().await?;
329
330        // Parse path to extract mount and secret path
331        // Expected format: "secret/data/myapp/tls" or "myapp/tls" (assumes "secret" mount)
332        let (mount, secret_path) = Self::parse_path(path);
333
334        // Read the secret
335        let secret: std::collections::HashMap<String, String> =
336            kv2::read(&client, &mount, &secret_path)
337                .await
338                .map_err(|e| {
339                    // Check for auth errors (token expired)
340                    if e.to_string().contains("403") || e.to_string().contains("permission denied")
341                    {
342                        SecretsError::AuthError("Vault token expired or invalid".into())
343                    } else {
344                        SecretsError::ProviderError(format!("failed to read secret {path}: {e}"))
345                    }
346                })?;
347
348        // Extract the requested key
349        let value = secret.get(key).ok_or_else(|| {
350            SecretsError::NotFound(format!("key '{key}' not found in secret '{path}'"))
351        })?;
352
353        let metadata = SecretMetadata {
354            version: None, // KV v2 version would require additional API call
355            source_path: Some(path.to_string()),
356            provider: Some("openbao".into()),
357        };
358
359        Ok(SecretValue::with_metadata(
360            value.as_bytes().to_vec(),
361            metadata,
362        ))
363    }
364
365    /// Parse a Vault path into mount and secret path.
366    ///
367    /// Handles formats:
368    /// - "secret/data/myapp/tls" -> ("secret", "myapp/tls")
369    /// - "myapp/tls" -> ("secret", "myapp/tls") (default mount)
370    fn parse_path(path: &str) -> (String, String) {
371        // Check for KV v2 "data" in path
372        if let Some(rest) = path.strip_prefix("secret/data/") {
373            return ("secret".into(), rest.into());
374        }
375
376        // Check for custom mount with "data" segment
377        let parts: Vec<&str> = path.splitn(3, '/').collect();
378        if parts.len() >= 3 && parts[1] == "data" {
379            return (parts[0].into(), parts[2..].join("/"));
380        }
381
382        // Default to "secret" mount
383        ("secret".into(), path.into())
384    }
385}
386
387impl SecretProvider for OpenBaoProvider {
388    async fn get(&self, path: &str, key: Option<&str>) -> SecretsResult<SecretValue> {
389        let key = key.ok_or_else(|| {
390            SecretsError::ConfigError("key is required for OpenBao secrets".into())
391        })?;
392        self.get(path, key).await
393    }
394
395    async fn health_check(&self) -> SecretsResult<()> {
396        let client = self.get_client().await?;
397
398        // Check sys/health endpoint
399        vaultrs::sys::health(&client)
400            .await
401            .map_err(|e| SecretsError::ProviderError(format!("Vault health check failed: {e}")))?;
402
403        Ok(())
404    }
405
406    fn name(&self) -> &'static str {
407        "openbao"
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_parse_path_with_mount() {
417        let (mount, path) = OpenBaoProvider::parse_path("secret/data/myapp/tls");
418        assert_eq!(mount, "secret");
419        assert_eq!(path, "myapp/tls");
420    }
421
422    #[test]
423    fn test_parse_path_custom_mount() {
424        let (mount, path) = OpenBaoProvider::parse_path("kv/data/myapp/creds");
425        assert_eq!(mount, "kv");
426        assert_eq!(path, "myapp/creds");
427    }
428
429    #[test]
430    fn test_parse_path_default_mount() {
431        let (mount, path) = OpenBaoProvider::parse_path("myapp/tls");
432        assert_eq!(mount, "secret");
433        assert_eq!(path, "myapp/tls");
434    }
435
436    #[test]
437    fn test_openbao_auth_token_serialization() {
438        let auth = OpenBaoAuth::Token {
439            token: "test-token".into(),
440        };
441        let json = serde_json::to_string(&auth).unwrap();
442        assert!(json.contains("\"method\":\"token\""));
443    }
444
445    #[test]
446    fn test_openbao_auth_approle_serialization() {
447        let auth = OpenBaoAuth::AppRole {
448            role_id: "role123".into(),
449            secret_id: "secret456".into(),
450            mount: "approle".into(),
451        };
452        let json = serde_json::to_string(&auth).unwrap();
453        assert!(json.contains("\"method\":\"app_role\""));
454        assert!(json.contains("role_id"));
455    }
456
457    #[test]
458    fn test_openbao_config_serialization() {
459        let config = OpenBaoConfig {
460            address: "https://vault.example.com:8200".into(),
461            auth: OpenBaoAuth::Token {
462                token: "test".into(),
463            },
464            namespace: Some("myorg".into()),
465            ca_cert: None,
466            skip_verify: false,
467        };
468        let json = serde_json::to_string(&config).unwrap();
469        assert!(json.contains("vault.example.com"));
470    }
471}