//! Secret storage with PostgreSQL persistence. //! //! Provides CRUD operations for encrypted secrets. The store handles: //! - Encryption/decryption via SecretsCrypto //! - Expiration checking //! - Usage tracking //! - Access control (which secrets a tool can use) use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; #[cfg(feature = "postgres")] use deadpool_postgres::Pool; use secrecy::ExposeSecret; use uuid::Uuid; use crate::secrets::crypto::SecretsCrypto; use crate::secrets::types::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef}; /// Trait for secret storage operations. /// /// Allows for different implementations (PostgreSQL, in-memory for testing). #[async_trait] pub trait SecretsStore: Send + Sync { /// Store a new secret. async fn create( &self, user_id: &str, params: CreateSecretParams, ) -> Result; /// Get a secret by name (encrypted form). async fn get(&self, user_id: &str, name: &str) -> Result; /// Get and decrypt a secret. async fn get_decrypted( &self, user_id: &str, name: &str, ) -> Result; /// Check if a secret exists. async fn exists(&self, user_id: &str, name: &str) -> Result; /// List all secret references for a user (no values). async fn list(&self, user_id: &str) -> Result, SecretError>; /// Delete a secret. async fn delete(&self, user_id: &str, name: &str) -> Result; /// Update secret usage tracking. async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError>; /// Check if a secret is accessible by a tool (based on allowed_secrets). async fn is_accessible( &self, user_id: &str, secret_name: &str, allowed_secrets: &[String], ) -> Result; } /// PostgreSQL implementation of SecretsStore. #[cfg(feature = "postgres")] pub struct PostgresSecretsStore { pool: Pool, crypto: Arc, } #[cfg(feature = "postgres")] impl PostgresSecretsStore { /// Create a new store with the given database pool and crypto instance. pub fn new(pool: Pool, crypto: Arc) -> Self { Self { pool, crypto } } } #[cfg(feature = "postgres")] #[async_trait] impl SecretsStore for PostgresSecretsStore { async fn create( &self, user_id: &str, params: CreateSecretParams, ) -> Result { let client = self .pool .get() .await .map_err(|e| SecretError::Database(e.to_string()))?; // Encrypt the secret value let plaintext = params.value.expose_secret().as_bytes(); let (encrypted_value, key_salt) = self.crypto.encrypt(plaintext)?; let id = Uuid::new_v4(); let now = Utc::now(); let row = client .query_one( r#" INSERT INTO secrets (id, user_id, name, encrypted_value, key_salt, provider, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) ON CONFLICT (user_id, name) DO UPDATE SET encrypted_value = EXCLUDED.encrypted_value, key_salt = EXCLUDED.key_salt, provider = EXCLUDED.provider, expires_at = EXCLUDED.expires_at, updated_at = NOW() RETURNING id, user_id, name, encrypted_value, key_salt, provider, expires_at, last_used_at, usage_count, created_at, updated_at "#, &[ &id, &user_id, ¶ms.name, &encrypted_value, &key_salt, ¶ms.provider, ¶ms.expires_at, &now, ], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(row_to_secret(&row)) } async fn get(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let client = self .pool .get() .await .map_err(|e| SecretError::Database(e.to_string()))?; let row = client .query_opt( r#" SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at, last_used_at, usage_count, created_at, updated_at FROM secrets WHERE user_id = $1 AND name = $2 "#, &[&user_id, &name], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; match row { Some(r) => { let secret = row_to_secret(&r); // Check expiration if let Some(expires_at) = secret.expires_at && expires_at < Utc::now() { return Err(SecretError::Expired); } Ok(secret) } None => Err(SecretError::NotFound(name.to_string())), } } async fn get_decrypted( &self, user_id: &str, name: &str, ) -> Result { let secret = self.get(user_id, name).await?; self.crypto .decrypt(&secret.encrypted_value, &secret.key_salt) } async fn exists(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let client = self .pool .get() .await .map_err(|e| SecretError::Database(e.to_string()))?; let row = client .query_one( "SELECT EXISTS(SELECT 1 FROM secrets WHERE user_id = $1 AND name = $2)", &[&user_id, &name], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(row.get(0)) } async fn list(&self, user_id: &str) -> Result, SecretError> { let client = self .pool .get() .await .map_err(|e| SecretError::Database(e.to_string()))?; let rows = client .query( "SELECT name, provider FROM secrets WHERE user_id = $1 ORDER BY name", &[&user_id], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(rows .into_iter() .map(|r| SecretRef { name: r.get(0), provider: r.get(1), }) .collect()) } async fn delete(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let client = self .pool .get() .await .map_err(|e| SecretError::Database(e.to_string()))?; let result = client .execute( "DELETE FROM secrets WHERE user_id = $1 AND name = $2", &[&user_id, &name], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(result > 0) } async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> { let client = self .pool .get() .await .map_err(|e| SecretError::Database(e.to_string()))?; client .execute( r#" UPDATE secrets SET last_used_at = NOW(), usage_count = usage_count + 1 WHERE id = $1 "#, &[&secret_id], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(()) } async fn is_accessible( &self, user_id: &str, secret_name: &str, allowed_secrets: &[String], ) -> Result { let secret_name_lower = secret_name.to_lowercase(); // First check if the secret exists if !self.exists(user_id, &secret_name_lower).await? { return Ok(false); } // Check if secret is in the allowed list // Supports glob patterns: "openai_*" matches "openai_api_key" for pattern in allowed_secrets { let pattern_lower = pattern.to_lowercase(); if pattern_lower == secret_name_lower { return Ok(true); } // Simple glob: * matches any suffix if let Some(prefix) = pattern_lower.strip_suffix('*') && secret_name_lower.starts_with(prefix) { return Ok(true); } } Ok(false) } } #[cfg(feature = "postgres")] fn row_to_secret(row: &tokio_postgres::Row) -> Secret { Secret { id: row.get("id"), user_id: row.get("user_id"), name: row.get("name"), encrypted_value: row.get("encrypted_value"), key_salt: row.get("key_salt"), provider: row.get("provider"), expires_at: row.get("expires_at"), last_used_at: row.get("last_used_at"), usage_count: row.get("usage_count"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), } } // ==================== libSQL implementation ==================== /// libSQL/Turso implementation of SecretsStore. /// /// Holds an `Arc` handle and creates a fresh connection per operation, /// matching the connection-per-request pattern used by the main `LibSqlBackend`. #[cfg(feature = "libsql")] pub struct LibSqlSecretsStore { db: Arc, crypto: Arc, } #[cfg(feature = "libsql")] impl LibSqlSecretsStore { /// Create a new store with the given shared libsql database handle and crypto instance. pub fn new(db: Arc, crypto: Arc) -> Self { Self { db, crypto } } async fn connect(&self) -> Result { let conn = self .db .connect() .map_err(|e| SecretError::Database(format!("Connection failed: {}", e)))?; conn.query("PRAGMA busy_timeout = 5000", ()) .await .map_err(|e| SecretError::Database(format!("Failed to set busy_timeout: {}", e)))?; Ok(conn) } } #[cfg(feature = "libsql")] #[async_trait] impl SecretsStore for LibSqlSecretsStore { async fn create( &self, user_id: &str, params: CreateSecretParams, ) -> Result { let plaintext = params.value.expose_secret().as_bytes(); let (encrypted_value, key_salt) = self.crypto.encrypt(plaintext)?; let id = Uuid::new_v4(); let now = Utc::now(); let now_str = now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let expires_at_str = params .expires_at .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); // Start transaction for atomic upsert + read-back let conn = self.connect().await?; let tx = conn .transaction() .await .map_err(|e| SecretError::Database(e.to_string()))?; tx.execute( r#" INSERT INTO secrets (id, user_id, name, encrypted_value, key_salt, provider, expires_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) ON CONFLICT (user_id, name) DO UPDATE SET encrypted_value = excluded.encrypted_value, key_salt = excluded.key_salt, provider = excluded.provider, expires_at = excluded.expires_at, updated_at = ?8 "#, libsql::params![ id.to_string(), user_id, params.name.as_str(), libsql::Value::Blob(encrypted_value.clone()), libsql::Value::Blob(key_salt.clone()), libsql_opt_text(params.provider.as_deref()), libsql_opt_text(expires_at_str.as_deref()), now_str.as_str(), ], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; // Read back the row (may have been upserted) let mut rows = tx .query( r#" SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at, last_used_at, usage_count, created_at, updated_at FROM secrets WHERE user_id = ?1 AND name = ?2 "#, libsql::params![user_id, params.name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; let row = rows .next() .await .map_err(|e| SecretError::Database(e.to_string()))? .ok_or_else(|| SecretError::Database("Insert succeeded but row not found".into()))?; let secret = libsql_row_to_secret(&row)?; tx.commit() .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(secret) } async fn get(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let conn = self.connect().await?; let mut rows = conn .query( r#" SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at, last_used_at, usage_count, created_at, updated_at FROM secrets WHERE user_id = ?1 AND name = ?2 "#, libsql::params![user_id, name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; match rows .next() .await .map_err(|e| SecretError::Database(e.to_string()))? { Some(row) => { let secret = libsql_row_to_secret(&row)?; if let Some(expires_at) = secret.expires_at && expires_at < Utc::now() { return Err(SecretError::Expired); } Ok(secret) } None => Err(SecretError::NotFound(name.to_string())), } } async fn get_decrypted( &self, user_id: &str, name: &str, ) -> Result { let secret = self.get(user_id, name).await?; self.crypto .decrypt(&secret.encrypted_value, &secret.key_salt) } async fn exists(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let conn = self.connect().await?; let mut rows = conn .query( "SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2", libsql::params![user_id, name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(rows .next() .await .map_err(|e| SecretError::Database(e.to_string()))? .is_some()) } async fn list(&self, user_id: &str) -> Result, SecretError> { let conn = self.connect().await?; let mut rows = conn .query( "SELECT name, provider FROM secrets WHERE user_id = ?1 ORDER BY name", libsql::params![user_id], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; let mut refs = Vec::new(); while let Some(row) = rows .next() .await .map_err(|e| SecretError::Database(e.to_string()))? { refs.push(SecretRef { name: row.get::(0).unwrap_or_default(), provider: row.get::(1).ok(), }); } Ok(refs) } async fn delete(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let conn = self.connect().await?; let affected = conn .execute( "DELETE FROM secrets WHERE user_id = ?1 AND name = ?2", libsql::params![user_id, name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(affected > 0) } async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> { let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let conn = self.connect().await?; conn.execute( r#" UPDATE secrets SET last_used_at = ?1, usage_count = usage_count + 1 WHERE id = ?2 "#, libsql::params![now.as_str(), secret_id.to_string()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; Ok(()) } async fn is_accessible( &self, user_id: &str, secret_name: &str, allowed_secrets: &[String], ) -> Result { let secret_name_lower = secret_name.to_lowercase(); if !self.exists(user_id, &secret_name_lower).await? { return Ok(false); } for pattern in allowed_secrets { let pattern_lower = pattern.to_lowercase(); if pattern_lower == secret_name_lower { return Ok(true); } if let Some(prefix) = pattern_lower.strip_suffix('*') && secret_name_lower.starts_with(prefix) { return Ok(true); } } Ok(false) } } #[cfg(feature = "libsql")] fn libsql_opt_text(s: Option<&str>) -> libsql::Value { match s { Some(s) => libsql::Value::Text(s.to_string()), None => libsql::Value::Null, } } #[cfg(feature = "libsql")] fn libsql_parse_timestamp(s: &str) -> Result, SecretError> { if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { return Ok(dt.with_timezone(&Utc)); } if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { return Ok(ndt.and_utc()); } if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return Ok(ndt.and_utc()); } Err(SecretError::Database(format!( "unparseable timestamp: {:?}", s ))) } #[cfg(feature = "libsql")] fn libsql_row_to_secret(row: &libsql::Row) -> Result { let id_str: String = row .get(0) .map_err(|e| SecretError::Database(e.to_string()))?; let user_id: String = row .get(1) .map_err(|e| SecretError::Database(e.to_string()))?; let name: String = row .get(2) .map_err(|e| SecretError::Database(e.to_string()))?; let encrypted_value: Vec = row .get(3) .map_err(|e| SecretError::Database(e.to_string()))?; let key_salt: Vec = row .get(4) .map_err(|e| SecretError::Database(e.to_string()))?; let provider: Option = row.get::(5).ok().filter(|s| !s.is_empty()); let expires_at = row .get::(6) .ok() .filter(|s| !s.is_empty()) .and_then(|s| libsql_parse_timestamp(&s).ok()); let last_used_at = row .get::(7) .ok() .filter(|s| !s.is_empty()) .and_then(|s| libsql_parse_timestamp(&s).ok()); let usage_count: i64 = row.get::(8).unwrap_or(0); let created_at_str: String = row .get(9) .map_err(|e| SecretError::Database(e.to_string()))?; let updated_at_str: String = row .get(10) .map_err(|e| SecretError::Database(e.to_string()))?; Ok(Secret { id: id_str .parse() .map_err(|e: uuid::Error| SecretError::Database(e.to_string()))?, user_id, name, encrypted_value, key_salt, provider, expires_at, last_used_at, usage_count, created_at: libsql_parse_timestamp(&created_at_str)?, updated_at: libsql_parse_timestamp(&updated_at_str)?, }) } /// In-memory secrets store. Used for testing and as a fallback when no /// persistent secrets backend is configured (extension listing/install still /// works, but stored secrets won't survive a restart). pub mod in_memory { use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use secrecy::ExposeSecret; use tokio::sync::RwLock; use uuid::Uuid; use crate::secrets::crypto::SecretsCrypto; use crate::secrets::store::SecretsStore; use crate::secrets::types::{ CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef, }; pub struct InMemorySecretsStore { secrets: RwLock>, crypto: Arc, } impl InMemorySecretsStore { pub fn new(crypto: Arc) -> Self { Self { secrets: RwLock::new(HashMap::new()), crypto, } } } #[async_trait] impl SecretsStore for InMemorySecretsStore { async fn create( &self, user_id: &str, params: CreateSecretParams, ) -> Result { let plaintext = params.value.expose_secret().as_bytes(); let (encrypted_value, key_salt) = self.crypto.encrypt(plaintext)?; let now = Utc::now(); let secret = Secret { id: Uuid::new_v4(), user_id: user_id.to_string(), name: params.name.clone(), encrypted_value, key_salt, provider: params.provider, expires_at: params.expires_at, last_used_at: None, usage_count: 0, created_at: now, updated_at: now, }; self.secrets .write() .await .insert((user_id.to_string(), params.name), secret.clone()); Ok(secret) } async fn get(&self, user_id: &str, name: &str) -> Result { let name = name.to_lowercase(); let secret = self .secrets .read() .await .get(&(user_id.to_string(), name.clone())) .cloned() .ok_or_else(|| SecretError::NotFound(name.clone()))?; if let Some(expires_at) = secret.expires_at && expires_at < Utc::now() { return Err(SecretError::Expired); } Ok(secret) } async fn get_decrypted( &self, user_id: &str, name: &str, ) -> Result { let secret = self.get(user_id, name).await?; self.crypto .decrypt(&secret.encrypted_value, &secret.key_salt) } async fn exists(&self, user_id: &str, name: &str) -> Result { Ok(self .secrets .read() .await .contains_key(&(user_id.to_string(), name.to_lowercase()))) } async fn list(&self, user_id: &str) -> Result, SecretError> { Ok(self .secrets .read() .await .iter() .filter(|((uid, _), _)| uid == user_id) .map(|((_, _), s)| SecretRef { name: s.name.clone(), provider: s.provider.clone(), }) .collect()) } async fn delete(&self, user_id: &str, name: &str) -> Result { Ok(self .secrets .write() .await .remove(&(user_id.to_string(), name.to_lowercase())) .is_some()) } async fn record_usage(&self, _secret_id: Uuid) -> Result<(), SecretError> { Ok(()) } async fn is_accessible( &self, user_id: &str, secret_name: &str, allowed_secrets: &[String], ) -> Result { let secret_name_lower = secret_name.to_lowercase(); if !self.exists(user_id, &secret_name_lower).await? { return Ok(false); } for pattern in allowed_secrets { let pattern_lower = pattern.to_lowercase(); if pattern_lower == secret_name_lower { return Ok(true); } if let Some(prefix) = pattern_lower.strip_suffix('*') && secret_name_lower.starts_with(prefix) { return Ok(true); } } Ok(false) } } } #[cfg(test)] mod tests { use std::sync::Arc; use secrecy::SecretString; use crate::secrets::crypto::SecretsCrypto; use crate::secrets::store::SecretsStore; use crate::secrets::store::in_memory::InMemorySecretsStore; use crate::secrets::types::CreateSecretParams; fn test_store() -> InMemorySecretsStore { let key = "0123456789abcdef0123456789abcdef"; let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap()); InMemorySecretsStore::new(crypto) } #[tokio::test] async fn test_create_and_get() { let store = test_store(); let params = CreateSecretParams::new("api_key", "sk-test-12345"); store.create("user1", params).await.unwrap(); let decrypted = store.get_decrypted("user1", "api_key").await.unwrap(); assert_eq!(decrypted.expose(), "sk-test-12345"); } #[tokio::test] async fn test_exists() { let store = test_store(); let params = CreateSecretParams::new("my_secret", "value"); assert!(!store.exists("user1", "my_secret").await.unwrap()); store.create("user1", params).await.unwrap(); assert!(store.exists("user1", "my_secret").await.unwrap()); } #[tokio::test] async fn test_delete() { let store = test_store(); let params = CreateSecretParams::new("to_delete", "value"); store.create("user1", params).await.unwrap(); assert!(store.exists("user1", "to_delete").await.unwrap()); store.delete("user1", "to_delete").await.unwrap(); assert!(!store.exists("user1", "to_delete").await.unwrap()); } #[tokio::test] async fn test_list() { let store = test_store(); store .create("user1", CreateSecretParams::new("key1", "v1")) .await .unwrap(); store .create( "user1", CreateSecretParams::new("key2", "v2").with_provider("openai"), ) .await .unwrap(); store .create("user2", CreateSecretParams::new("key3", "v3")) .await .unwrap(); let list = store.list("user1").await.unwrap(); assert_eq!(list.len(), 2); } #[tokio::test] async fn test_is_accessible() { let store = test_store(); store .create("user1", CreateSecretParams::new("openai_key", "sk-test")) .await .unwrap(); store .create("user1", CreateSecretParams::new("stripe_key", "sk-live")) .await .unwrap(); // Exact match let allowed = vec!["openai_key".to_string()]; assert!( store .is_accessible("user1", "openai_key", &allowed) .await .unwrap() ); assert!( !store .is_accessible("user1", "stripe_key", &allowed) .await .unwrap() ); // Glob pattern let allowed = vec!["openai_*".to_string()]; assert!( store .is_accessible("user1", "openai_key", &allowed) .await .unwrap() ); assert!( !store .is_accessible("user1", "stripe_key", &allowed) .await .unwrap() ); } #[tokio::test] async fn test_expired_secret_returns_error() { let store = test_store(); let expires_at = chrono::Utc::now() - chrono::Duration::hours(1); let params = CreateSecretParams::new("expired_key", "value").with_expiry(expires_at); store.create("user1", params).await.unwrap(); let result = store.get("user1", "expired_key").await; assert!(result.is_err()); assert!(matches!( result.unwrap_err(), crate::secrets::SecretError::Expired )); } #[tokio::test] async fn test_non_expired_secret_succeeds() { let store = test_store(); let expires_at = chrono::Utc::now() + chrono::Duration::hours(1); let params = CreateSecretParams::new("fresh_key", "value").with_expiry(expires_at); store.create("user1", params).await.unwrap(); let result = store.get("user1", "fresh_key").await; assert!(result.is_ok()); } #[tokio::test] async fn test_user_isolation() { let store = test_store(); store .create( "user1", CreateSecretParams::new("shared_name", "user1_value"), ) .await .unwrap(); store .create( "user2", CreateSecretParams::new("shared_name", "user2_value"), ) .await .unwrap(); let v1 = store.get_decrypted("user1", "shared_name").await.unwrap(); let v2 = store.get_decrypted("user2", "shared_name").await.unwrap(); assert_eq!(v1.expose(), "user1_value"); assert_eq!(v2.expose(), "user2_value"); } }