Skip to main content

symbi_runtime/secrets/
vault_backend.rs

1//! HashiCorp Vault secrets backend implementation
2//!
3//! This module provides a Vault-based secrets store that supports multiple authentication
4//! methods and interacts with Vault's KV v2 secrets engine.
5
6use super::{BoxedAuditSink, Secret, SecretAuditEvent, SecretError, SecretStore};
7use crate::secrets::config::{VaultAuthConfig, VaultConfig};
8use async_trait::async_trait;
9use std::time::Duration;
10use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
11use vaultrs::error::ClientError;
12use vaultrs::kv2;
13use vaultrs::token;
14
15/// Vault-based secrets store implementation
16pub struct VaultSecretStore {
17    client: VaultClient,
18    config: VaultConfig,
19    agent_id: String,
20    audit_sink: Option<BoxedAuditSink>,
21}
22
23impl VaultSecretStore {
24    /// Create a new VaultSecretStore with the given configuration and agent ID
25    pub async fn new(
26        config: VaultConfig,
27        agent_id: String,
28        audit_sink: Option<BoxedAuditSink>,
29    ) -> Result<Self, SecretError> {
30        let client = Self::create_vault_client(&config).await?;
31
32        Ok(Self {
33            client,
34            config,
35            agent_id,
36            audit_sink,
37        })
38    }
39
40    /// Create and configure a Vault client
41    async fn create_vault_client(config: &VaultConfig) -> Result<VaultClient, SecretError> {
42        let mut settings_builder = VaultClientSettingsBuilder::default();
43        settings_builder.address(&config.url);
44
45        // Configure namespace if specified
46        if let Some(namespace) = &config.namespace {
47            settings_builder.namespace(Some(namespace.clone()));
48        }
49
50        // Configure TLS settings
51        if config.tls.skip_verify {
52            if std::env::var("SYMBIONT_ENV").unwrap_or_default() == "production" {
53                return Err(SecretError::ConfigurationError {
54                    message: "TLS verification cannot be disabled in production (SYMBIONT_ENV=production). \
55                              Remove tls.skip_verify or set SYMBIONT_ENV to a non-production value."
56                        .into(),
57                });
58            }
59            tracing::warn!(
60                "Vault TLS verification is disabled — connections are vulnerable to MitM attacks"
61            );
62            settings_builder.verify(false);
63        }
64
65        // Set timeouts
66        let connection_timeout = Duration::from_secs(config.connection.connection_timeout_seconds);
67        settings_builder.timeout(Some(connection_timeout));
68
69        let settings = settings_builder
70            .build()
71            .map_err(|e| SecretError::ConfigurationError {
72                message: format!("Failed to build Vault client settings: {}", e),
73            })?;
74
75        let client = VaultClient::new(settings).map_err(|e| SecretError::ConnectionError {
76            message: format!("Failed to create Vault client: {}", e),
77        })?;
78
79        Ok(client)
80    }
81
82    /// Authenticate with Vault using the configured authentication method
83    pub async fn authenticate(&mut self) -> Result<(), SecretError> {
84        let auth_config = self.config.auth.clone();
85        match auth_config {
86            VaultAuthConfig::Token { token } => {
87                self.client.set_token(&token);
88                // Verify token by checking our own capabilities
89                self.verify_token().await
90            }
91            VaultAuthConfig::Kubernetes {
92                token_path,
93                role,
94                mount_path,
95            } => {
96                self.authenticate_kubernetes(&token_path, &role, &mount_path)
97                    .await
98            }
99            VaultAuthConfig::Aws {
100                region,
101                role,
102                mount_path,
103            } => self.authenticate_aws(&region, &role, &mount_path).await,
104            VaultAuthConfig::AppRole {
105                role_id,
106                secret_id,
107                mount_path,
108            } => {
109                self.authenticate_approle(&role_id, &secret_id, &mount_path)
110                    .await
111            }
112        }
113    }
114
115    /// Verify the current token is valid
116    async fn verify_token(&self) -> Result<(), SecretError> {
117        match token::lookup_self(&self.client).await {
118            Ok(_) => Ok(()),
119            Err(e) => Err(self.map_vault_error(e)),
120        }
121    }
122
123    /// Authenticate using Kubernetes service account token
124    async fn authenticate_kubernetes(
125        &mut self,
126        token_path: &str,
127        role: &str,
128        mount_path: &str,
129    ) -> Result<(), SecretError> {
130        // Read the service account token
131        let jwt = tokio::fs::read_to_string(token_path).await.map_err(|e| {
132            SecretError::AuthenticationFailed {
133                message: format!("Failed to read Kubernetes token from {}: {}", token_path, e),
134            }
135        })?;
136
137        // Authenticate with Vault
138        let auth_info = vaultrs::auth::kubernetes::login(&self.client, mount_path, role, &jwt)
139            .await
140            .map_err(|e| SecretError::AuthenticationFailed {
141                message: format!("Kubernetes authentication failed: {}", e),
142            })?;
143
144        self.client.set_token(&auth_info.client_token);
145        Ok(())
146    }
147
148    /// Authenticate using AWS IAM role
149    async fn authenticate_aws(
150        &mut self,
151        _region: &str,
152        role: &str,
153        _mount_path: &str,
154    ) -> Result<(), SecretError> {
155        // For AWS authentication, we need to use the AWS SDK to get credentials
156        // and create a signed request. This is a simplified implementation.
157        // In a production environment, you would use the AWS SDK to properly
158        // generate the required authentication data.
159
160        // This would require additional dependencies and AWS credential configuration
161        // For now, return an error indicating this needs to be implemented
162        Err(SecretError::UnsupportedOperation {
163            operation: format!(
164                "AWS IAM authentication not yet implemented for role: {}",
165                role
166            ),
167        })
168    }
169
170    /// Authenticate using AppRole
171    async fn authenticate_approle(
172        &mut self,
173        role_id: &str,
174        secret_id: &str,
175        mount_path: &str,
176    ) -> Result<(), SecretError> {
177        let auth_info = vaultrs::auth::approle::login(&self.client, mount_path, role_id, secret_id)
178            .await
179            .map_err(|e| SecretError::AuthenticationFailed {
180                message: format!("AppRole authentication failed: {}", e),
181            })?;
182
183        self.client.set_token(&auth_info.client_token);
184        Ok(())
185    }
186
187    /// Get the full path for a secret key
188    fn get_secret_path(&self, key: &str) -> String {
189        format!("agents/{}/secrets/{}", self.agent_id, key)
190    }
191
192    /// Get the base path for listing secrets
193    fn get_base_path(&self) -> String {
194        format!("agents/{}/secrets", self.agent_id)
195    }
196
197    /// Map Vault client errors to SecretError
198    fn map_vault_error(&self, error: ClientError) -> SecretError {
199        match error {
200            ClientError::RestClientError { .. } => SecretError::ConnectionError {
201                message: error.to_string(),
202            },
203            ClientError::APIError { code: 404, .. } => SecretError::NotFound {
204                key: "unknown".to_string(), // We don't always have the key context
205            },
206            ClientError::APIError { code: 403, .. } => SecretError::PermissionDenied {
207                key: "unknown".to_string(),
208            },
209            ClientError::APIError { code: 401, .. } => SecretError::AuthenticationFailed {
210                message: "Vault authentication failed".to_string(),
211            },
212            ClientError::APIError { code: 429, .. } => SecretError::RateLimitExceeded {
213                message: "Vault rate limit exceeded".to_string(),
214            },
215            _ => SecretError::BackendError {
216                message: error.to_string(),
217            },
218        }
219    }
220
221    /// Log an audit event if an audit sink is configured.
222    /// In strict mode, returns an error if audit logging fails.
223    /// In permissive mode, logs a warning and continues.
224    async fn log_audit_event(&self, event: SecretAuditEvent) -> Result<(), SecretError> {
225        if let Some(audit_sink) = &self.audit_sink {
226            if let Err(e) = audit_sink.log_event(event).await {
227                match audit_sink.failure_mode() {
228                    crate::secrets::auditing::AuditFailureMode::Strict => {
229                        return Err(SecretError::AuditFailed {
230                            message: format!("Audit logging failed (strict mode): {}", e),
231                        });
232                    }
233                    crate::secrets::auditing::AuditFailureMode::Permissive => {
234                        tracing::warn!("Audit logging failed (permissive mode): {}", e);
235                    }
236                }
237            }
238        }
239        Ok(())
240    }
241}
242
243#[async_trait]
244impl SecretStore for VaultSecretStore {
245    /// Retrieve a secret by key from Vault KV v2
246    async fn get_secret(&self, key: &str) -> Result<Secret, SecretError> {
247        // Intent log — ensures a paper trail even if the process crashes
248        // during the Vault call.
249        self.log_audit_event(SecretAuditEvent::attempt(
250            self.agent_id.clone(),
251            "get_secret".to_string(),
252            Some(key.to_string()),
253        ))
254        .await?;
255
256        let path = self.get_secret_path(key);
257
258        let result: Result<Secret, SecretError> = async {
259            match kv2::read::<serde_json::Value>(&self.client, &self.config.mount_path, &path).await
260            {
261                Ok(secret_response) => {
262                    // Extract the secret data from the Vault KVv2 response structure
263                    let data = secret_response
264                        .get("data")
265                        .and_then(|d| d.get("data"))
266                        .ok_or_else(|| SecretError::BackendError {
267                            message: "Invalid Vault response structure".to_string(),
268                        })?;
269
270                    // Extract the secret value from a well-known key.
271                    // We require secrets to use "value" or "content" — no
272                    // heuristic fallback to the "first string value" which
273                    // is non-deterministic on unordered JSON objects and could
274                    // silently return the wrong field.
275                    let secret_value = data
276                        .get("value")
277                        .or_else(|| data.get("content"))
278                        .and_then(|v| v.as_str())
279                        .map(|s| s.to_string())
280                        .ok_or_else(|| {
281                            let available_keys: Vec<&str> = data
282                                .as_object()
283                                .map(|obj| obj.keys().map(|k| k.as_str()).collect())
284                                .unwrap_or_default();
285                            SecretError::BackendError {
286                                message: format!(
287                                    "Secret '{}' has no 'value' or 'content' key. \
288                                     Available keys: [{}]. Store secrets with a 'value' key, \
289                                     or use get_secret_field() to request a specific key.",
290                                    key,
291                                    available_keys.join(", ")
292                                ),
293                            }
294                        })?;
295
296                    // Extract metadata from the Vault response
297                    let mut metadata = std::collections::HashMap::new();
298                    if let Some(metadata_obj) =
299                        secret_response.get("data").and_then(|d| d.get("metadata"))
300                    {
301                        if let Some(created_time) =
302                            metadata_obj.get("created_time").and_then(|v| v.as_str())
303                        {
304                            metadata.insert("created_time".to_string(), created_time.to_string());
305                        }
306                        if let Some(version) = metadata_obj.get("version").and_then(|v| v.as_u64())
307                        {
308                            metadata.insert("version".to_string(), version.to_string());
309                        }
310                        if let Some(destroyed) =
311                            metadata_obj.get("destroyed").and_then(|v| v.as_bool())
312                        {
313                            metadata.insert("destroyed".to_string(), destroyed.to_string());
314                        }
315                        if let Some(deletion_time) =
316                            metadata_obj.get("deletion_time").and_then(|v| v.as_str())
317                        {
318                            if !deletion_time.is_empty() && deletion_time != "null" {
319                                metadata
320                                    .insert("deletion_time".to_string(), deletion_time.to_string());
321                            }
322                        }
323                    }
324
325                    // Parse created_at timestamp if available - convert to string
326                    let created_at = metadata.get("created_time").cloned();
327
328                    // Parse version if available - convert to string
329                    let version = metadata.get("version").cloned();
330
331                    Ok(Secret {
332                        key: key.to_string(),
333                        value: secret_value,
334                        metadata: Some(metadata),
335                        created_at,
336                        version,
337                    })
338                }
339                Err(e) => {
340                    let mapped_error = self.map_vault_error(e);
341                    // Update the key context if it's a NotFound error
342                    match mapped_error {
343                        SecretError::NotFound { .. } => Err(SecretError::NotFound {
344                            key: key.to_string(),
345                        }),
346                        SecretError::PermissionDenied { .. } => {
347                            Err(SecretError::PermissionDenied {
348                                key: key.to_string(),
349                            })
350                        }
351                        other => Err(other),
352                    }
353                }
354            }
355        }
356        .await;
357
358        // Log audit event — in strict mode, audit failure blocks the operation
359        let audit_event = match &result {
360            Ok(_) => SecretAuditEvent::success(
361                self.agent_id.clone(),
362                "get_secret".to_string(),
363                Some(key.to_string()),
364            ),
365            Err(e) => SecretAuditEvent::failure(
366                self.agent_id.clone(),
367                "get_secret".to_string(),
368                Some(key.to_string()),
369                e.to_string(),
370            ),
371        };
372        self.log_audit_event(audit_event).await?;
373
374        result
375    }
376
377    /// List all secret keys under the agent's secrets path
378    async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
379        self.log_audit_event(SecretAuditEvent::attempt(
380            self.agent_id.clone(),
381            "list_secrets".to_string(),
382            None,
383        ))
384        .await?;
385
386        let base_path = self.get_base_path();
387
388        let result: Result<Vec<String>, SecretError> = async {
389            match kv2::list(&self.client, &self.config.mount_path, &base_path).await {
390                Ok(list_response) => {
391                    // list_response is already a Vec<String> of keys
392                    Ok(list_response)
393                }
394                Err(e) => {
395                    let mapped_error = self.map_vault_error(e);
396                    // If the path doesn't exist (404), return empty list instead of error
397                    match mapped_error {
398                        SecretError::NotFound { .. } => Ok(vec![]),
399                        other => Err(other),
400                    }
401                }
402            }
403        }
404        .await;
405
406        // Log audit event — in strict mode, audit failure blocks the operation
407        let audit_event = match &result {
408            Ok(keys) => {
409                SecretAuditEvent::success(self.agent_id.clone(), "list_secrets".to_string(), None)
410                    .with_metadata(serde_json::json!({
411                        "secrets_count": keys.len()
412                    }))
413            }
414            Err(e) => SecretAuditEvent::failure(
415                self.agent_id.clone(),
416                "list_secrets".to_string(),
417                None,
418                e.to_string(),
419            ),
420        };
421        self.log_audit_event(audit_event).await?;
422
423        result
424    }
425}
426
427impl VaultSecretStore {
428    /// Retrieve a specific field from a Vault secret by key.
429    ///
430    /// Use this when the secret contains multiple fields (e.g. `username`,
431    /// `password`) and you need to access a specific one rather than the
432    /// default `value`/`content` key used by [`get_secret`].
433    pub async fn get_secret_field(&self, key: &str, field: &str) -> Result<Secret, SecretError> {
434        self.log_audit_event(
435            SecretAuditEvent::attempt(
436                self.agent_id.clone(),
437                "get_secret_field".to_string(),
438                Some(key.to_string()),
439            )
440            .with_metadata(serde_json::json!({ "field": field })),
441        )
442        .await?;
443
444        let path = self.get_secret_path(key);
445
446        let result: Result<Secret, SecretError> = async {
447            match kv2::read::<serde_json::Value>(&self.client, &self.config.mount_path, &path).await
448            {
449                Ok(secret_response) => {
450                    let data = secret_response
451                        .get("data")
452                        .and_then(|d| d.get("data"))
453                        .ok_or_else(|| SecretError::BackendError {
454                            message: "Invalid Vault response structure".to_string(),
455                        })?;
456
457                    let secret_value = data
458                        .get(field)
459                        .and_then(|v| v.as_str())
460                        .map(|s| s.to_string())
461                        .ok_or_else(|| {
462                            let available_keys: Vec<&str> = data
463                                .as_object()
464                                .map(|obj| obj.keys().map(|k| k.as_str()).collect())
465                                .unwrap_or_default();
466                            SecretError::BackendError {
467                                message: format!(
468                                    "Secret '{}' has no field '{}'. Available keys: [{}]",
469                                    key,
470                                    field,
471                                    available_keys.join(", ")
472                                ),
473                            }
474                        })?;
475
476                    Ok(Secret::new(key.to_string(), secret_value))
477                }
478                Err(e) => {
479                    let mapped = self.map_vault_error(e);
480                    match mapped {
481                        SecretError::NotFound { .. } => Err(SecretError::NotFound {
482                            key: key.to_string(),
483                        }),
484                        SecretError::PermissionDenied { .. } => {
485                            Err(SecretError::PermissionDenied {
486                                key: key.to_string(),
487                            })
488                        }
489                        other => Err(other),
490                    }
491                }
492            }
493        }
494        .await;
495
496        let audit_event = match &result {
497            Ok(_) => SecretAuditEvent::success(
498                self.agent_id.clone(),
499                "get_secret_field".to_string(),
500                Some(key.to_string()),
501            )
502            .with_metadata(serde_json::json!({ "field": field })),
503            Err(e) => SecretAuditEvent::failure(
504                self.agent_id.clone(),
505                "get_secret_field".to_string(),
506                Some(key.to_string()),
507                e.to_string(),
508            )
509            .with_metadata(serde_json::json!({ "field": field })),
510        };
511        self.log_audit_event(audit_event).await?;
512
513        result
514    }
515
516    /// List secrets with prefix filtering
517    pub async fn list_secrets_with_prefix(&self, prefix: &str) -> Result<Vec<String>, SecretError> {
518        let all_keys = self.list_secrets().await?;
519        Ok(all_keys
520            .into_iter()
521            .filter(|key| key.starts_with(prefix))
522            .collect())
523    }
524
525    /// Get the agent ID for this store
526    pub fn agent_id(&self) -> &str {
527        &self.agent_id
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::secrets::config::{VaultConnectionConfig, VaultTlsConfig};
535
536    fn create_test_config() -> VaultConfig {
537        VaultConfig {
538            url: "http://localhost:8200".to_string(),
539            auth: VaultAuthConfig::Token {
540                token: "test-token".to_string(),
541            },
542            namespace: None,
543            mount_path: "secret".to_string(),
544            api_version: "v2".to_string(),
545            tls: VaultTlsConfig::default(),
546            connection: VaultConnectionConfig::default(),
547        }
548    }
549
550    #[test]
551    fn test_secret_path_generation() {
552        let _config = create_test_config();
553        // We can't easily test the full VaultSecretStore without a real Vault instance
554        // but we can test path generation logic
555        let agent_id = "test-agent-123";
556        let expected_path = format!("agents/{}/secrets/my-key", agent_id);
557
558        // This would normally be done in the VaultSecretStore
559        let path = format!("agents/{}/secrets/{}", agent_id, "my-key");
560        assert_eq!(path, expected_path);
561    }
562
563    #[test]
564    fn test_base_path_generation() {
565        let agent_id = "test-agent-123";
566        let expected_base = format!("agents/{}/secrets", agent_id);
567
568        let base_path = format!("agents/{}/secrets", agent_id);
569        assert_eq!(base_path, expected_base);
570    }
571}