use crate::settings::secrets::{
SecretError, SecretMetadata, SecretProvider, SecretResult, SecretString,
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct CachedSecret {
value: SecretString,
expires_at: Instant,
}
impl CachedSecret {
fn is_valid(&self) -> bool {
Instant::now() < self.expires_at
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct VaultConfig {
pub addr: String,
pub token: String,
pub mount: String,
pub namespace: Option<String>,
pub cache_ttl: Duration,
}
impl VaultConfig {
pub fn new(addr: impl Into<String>, token: impl Into<String>) -> Self {
Self {
addr: addr.into(),
token: token.into(),
mount: "secret".to_string(),
namespace: None,
cache_ttl: Duration::from_secs(300), }
}
pub fn with_mount(mut self, mount: impl Into<String>) -> Self {
self.mount = mount.into();
self
}
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
self.cache_ttl = ttl;
self
}
}
pub struct VaultSecretProvider {
config: VaultConfig,
client: reqwest::Client,
cache: Arc<RwLock<HashMap<String, CachedSecret>>>,
}
impl VaultSecretProvider {
pub fn new(config: VaultConfig) -> SecretResult<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| SecretError::ProviderError(format!("Failed to create client: {}", e)))?;
Ok(Self {
config,
client,
cache: Arc::new(RwLock::new(HashMap::new())),
})
}
fn secret_path(&self, key: &str) -> String {
format!("{}/data/{}", self.config.mount, key)
}
fn metadata_path(&self, key: &str) -> String {
format!("{}/metadata/{}", self.config.mount, key)
}
fn build_url(&self, path: &str) -> String {
format!("{}/v1/{}", self.config.addr.trim_end_matches('/'), path)
}
async fn request<T: for<'de> Deserialize<'de>>(
&self,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> SecretResult<T> {
let url = self.build_url(path);
let mut req = self
.client
.request(method, &url)
.header("X-Vault-Token", self.config.token.clone());
if let Some(ns) = &self.config.namespace {
req = req.header("X-Vault-Namespace", ns);
}
if let Some(body) = body {
req = req.json(&body);
}
let response = req
.send()
.await
.map_err(|e| SecretError::NetworkError(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(SecretError::ProviderError(format!(
"Vault request failed with status {}: {}",
status, error_text
)));
}
response
.json()
.await
.map_err(|e| SecretError::ProviderError(format!("Failed to parse response: {}", e)))
}
}
#[derive(Debug, Deserialize)]
struct VaultReadResponse {
data: VaultData,
}
#[derive(Debug, Deserialize)]
struct VaultData {
data: HashMap<String, String>,
#[allow(dead_code)]
metadata: VaultSecretMetadata,
}
#[derive(Debug, Deserialize)]
struct VaultSecretMetadata {
#[allow(dead_code)]
created_time: String,
#[allow(dead_code)]
version: u64,
}
#[derive(Debug, Serialize)]
#[allow(dead_code)]
struct VaultWriteRequest {
data: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct VaultListResponse {
data: VaultListData,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct VaultListData {
keys: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct VaultMetadataResponse {
data: VaultMetadataData,
}
#[derive(Debug, Deserialize)]
struct VaultMetadataData {
#[allow(dead_code)]
versions: HashMap<String, VaultVersionInfo>,
created_time: String,
updated_time: String,
}
#[derive(Debug, Deserialize)]
struct VaultVersionInfo {
#[allow(dead_code)]
created_time: String,
#[allow(dead_code)]
deletion_time: String,
#[allow(dead_code)]
destroyed: bool,
}
#[async_trait]
impl SecretProvider for VaultSecretProvider {
async fn get_secret(&self, key: &str) -> SecretResult<SecretString> {
{
let cache = self.cache.read().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = cache.get(key)
&& cached.is_valid()
{
return Ok(cached.value.clone());
}
}
let path = self.secret_path(key);
let response: VaultReadResponse = self.request(reqwest::Method::GET, &path, None).await?;
let value = response
.data
.data
.get("value")
.ok_or_else(|| SecretError::NotFound(format!("Secret not found: {}", key)))?;
let secret = SecretString::new(value.clone());
{
let mut cache = self.cache.write().unwrap_or_else(|e| e.into_inner());
cache.insert(
key.to_string(),
CachedSecret {
value: secret.clone(),
expires_at: Instant::now() + self.config.cache_ttl,
},
);
}
Ok(secret)
}
async fn get_secret_with_metadata(
&self,
key: &str,
) -> SecretResult<(SecretString, SecretMetadata)> {
let secret = self.get_secret(key).await?;
let metadata_path = self.metadata_path(key);
let response: VaultMetadataResponse = self
.request(reqwest::Method::GET, &metadata_path, None)
.await?;
let created_at = chrono::DateTime::parse_from_rfc3339(&response.data.created_time)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc));
let updated_at = chrono::DateTime::parse_from_rfc3339(&response.data.updated_time)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc));
let metadata = SecretMetadata {
created_at,
updated_at,
};
Ok((secret, metadata))
}
async fn set_secret(&self, key: &str, value: SecretString) -> SecretResult<()> {
let path = self.secret_path(key);
let mut data = HashMap::new();
data.insert("value".to_string(), value.expose_secret().to_string());
let body = serde_json::json!({ "data": data });
let _: serde_json::Value = self
.request(reqwest::Method::POST, &path, Some(body))
.await?;
{
let mut cache = self.cache.write().unwrap_or_else(|e| e.into_inner());
cache.remove(key);
}
Ok(())
}
async fn delete_secret(&self, key: &str) -> SecretResult<()> {
let metadata_path = self.metadata_path(key);
let _: serde_json::Value = self
.request(reqwest::Method::DELETE, &metadata_path, None)
.await?;
{
let mut cache = self.cache.write().unwrap_or_else(|e| e.into_inner());
cache.remove(key);
}
Ok(())
}
async fn list_secrets(&self) -> SecretResult<Vec<String>> {
Ok(vec![])
}
fn exists(&self, key: &str) -> bool {
let cache = self.cache.read().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = cache.get(key) {
cached.is_valid()
} else {
false
}
}
fn name(&self) -> &str {
"vault"
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[tokio::test]
async fn test_vault_provider_basic() {
let mut server = mockito::Server::new_async().await;
let _m_set = server
.mock("POST", "/v1/secret/data/test/password")
.match_header("X-Vault-Token", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":{"version":1}}"#)
.expect(1)
.create_async()
.await;
let _m_get1 = server
.mock("GET", "/v1/secret/data/test/password")
.match_header("X-Vault-Token", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":{"data":{"value":"my-vault-secret"},"metadata":{"created_time":"2024-01-01T00:00:00Z","version":1}}}"#)
.expect(1)
.create_async()
.await;
let _m_delete = server
.mock("DELETE", "/v1/secret/metadata/test/password")
.match_header("X-Vault-Token", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{}"#)
.expect(1)
.create_async()
.await;
let _m_get2 = server
.mock("GET", "/v1/secret/data/test/password")
.match_header("X-Vault-Token", "test-token")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"errors":["secret not found"]}"#)
.expect(1)
.create_async()
.await;
let config = VaultConfig::new(server.url(), "test-token");
let provider = VaultSecretProvider::new(config).unwrap();
let secret = SecretString::new("my-vault-secret");
provider.set_secret("test/password", secret).await.unwrap();
let retrieved = provider.get_secret("test/password").await.unwrap();
assert_eq!(retrieved.expose_secret(), "my-vault-secret");
provider.delete_secret("test/password").await.unwrap();
let result = provider.get_secret("test/password").await;
assert!(result.is_err());
}
#[rstest]
#[test]
fn test_poisoned_read_lock_does_not_panic() {
let config = VaultConfig::new("http://127.0.0.1:8200", "test-token");
let provider = VaultSecretProvider::new(config).unwrap();
let cache_clone = Arc::clone(&provider.cache);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = cache_clone.write().unwrap();
panic!("intentional panic to poison lock");
}));
let result = provider.exists("any_key");
assert_eq!(result, false);
}
#[rstest]
#[test]
fn test_poisoned_write_lock_does_not_panic_on_exists() {
let config = VaultConfig::new("http://127.0.0.1:8200", "test-token").with_cache_ttl(
std::time::Duration::from_secs(3600), );
let provider = VaultSecretProvider::new(config).unwrap();
{
let mut cache = provider.cache.write().unwrap();
cache.insert(
"cached_key".to_string(),
CachedSecret {
value: SecretString::new("cached_value"),
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(3600),
},
);
}
let cache_clone = Arc::clone(&provider.cache);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = cache_clone.write().unwrap();
panic!("intentional panic to poison lock");
}));
let result = provider.exists("cached_key");
assert_eq!(result, true);
}
}