1use 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
15pub struct VaultSecretStore {
17 client: VaultClient,
18 config: VaultConfig,
19 agent_id: String,
20 audit_sink: Option<BoxedAuditSink>,
21}
22
23impl VaultSecretStore {
24 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 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 if let Some(namespace) = &config.namespace {
47 settings_builder.namespace(Some(namespace.clone()));
48 }
49
50 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 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 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 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(®ion, &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 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 async fn authenticate_kubernetes(
125 &mut self,
126 token_path: &str,
127 role: &str,
128 mount_path: &str,
129 ) -> Result<(), SecretError> {
130 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 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 async fn authenticate_aws(
150 &mut self,
151 _region: &str,
152 role: &str,
153 _mount_path: &str,
154 ) -> Result<(), SecretError> {
155 Err(SecretError::UnsupportedOperation {
163 operation: format!(
164 "AWS IAM authentication not yet implemented for role: {}",
165 role
166 ),
167 })
168 }
169
170 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 fn get_secret_path(&self, key: &str) -> String {
189 format!("agents/{}/secrets/{}", self.agent_id, key)
190 }
191
192 fn get_base_path(&self) -> String {
194 format!("agents/{}/secrets", self.agent_id)
195 }
196
197 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(), },
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 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 async fn get_secret(&self, key: &str) -> Result<Secret, SecretError> {
247 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 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 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 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 let created_at = metadata.get("created_time").cloned();
327
328 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 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 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 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 Ok(list_response)
393 }
394 Err(e) => {
395 let mapped_error = self.map_vault_error(e);
396 match mapped_error {
398 SecretError::NotFound { .. } => Ok(vec![]),
399 other => Err(other),
400 }
401 }
402 }
403 }
404 .await;
405
406 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 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 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 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 let agent_id = "test-agent-123";
556 let expected_path = format!("agents/{}/secrets/my-key", agent_id);
557
558 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}