mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
The Slack channel capabilities.json declares secret names in lowercase (slack_bot_token) but the web UI stored them in UPPERCASE (SLACK_BOT_TOKEN), causing credential injection to fail with "not_authed". Changes: - CreateSecretParams::new() normalizes name to lowercase on creation - All SecretsStore lookups (get, exists, delete, is_accessible) now lowercase the name parameter before querying - Applied to all three backends: PostgreSQL, libSQL, InMemory - CredentialInjector::is_secret_allowed() uses case-insensitive comparison Co-authored-by: Claude Opus 4.6 <[email protected]>
974 lines
30 KiB
Rust
974 lines
30 KiB
Rust
//! 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<Secret, SecretError>;
|
|
|
|
/// Get a secret by name (encrypted form).
|
|
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError>;
|
|
|
|
/// Get and decrypt a secret.
|
|
async fn get_decrypted(
|
|
&self,
|
|
user_id: &str,
|
|
name: &str,
|
|
) -> Result<DecryptedSecret, SecretError>;
|
|
|
|
/// Check if a secret exists.
|
|
async fn exists(&self, user_id: &str, name: &str) -> Result<bool, SecretError>;
|
|
|
|
/// List all secret references for a user (no values).
|
|
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError>;
|
|
|
|
/// Delete a secret.
|
|
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError>;
|
|
|
|
/// 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<bool, SecretError>;
|
|
}
|
|
|
|
/// PostgreSQL implementation of SecretsStore.
|
|
#[cfg(feature = "postgres")]
|
|
pub struct PostgresSecretsStore {
|
|
pool: Pool,
|
|
crypto: Arc<SecretsCrypto>,
|
|
}
|
|
|
|
#[cfg(feature = "postgres")]
|
|
impl PostgresSecretsStore {
|
|
/// Create a new store with the given database pool and crypto instance.
|
|
pub fn new(pool: Pool, crypto: Arc<SecretsCrypto>) -> Self {
|
|
Self { pool, crypto }
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "postgres")]
|
|
#[async_trait]
|
|
impl SecretsStore for PostgresSecretsStore {
|
|
async fn create(
|
|
&self,
|
|
user_id: &str,
|
|
params: CreateSecretParams,
|
|
) -> Result<Secret, SecretError> {
|
|
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<Secret, SecretError> {
|
|
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<DecryptedSecret, SecretError> {
|
|
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<bool, SecretError> {
|
|
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<Vec<SecretRef>, 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<bool, SecretError> {
|
|
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<bool, SecretError> {
|
|
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<Database>` 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<libsql::Database>,
|
|
crypto: Arc<SecretsCrypto>,
|
|
}
|
|
|
|
#[cfg(feature = "libsql")]
|
|
impl LibSqlSecretsStore {
|
|
/// Create a new store with the given shared libsql database handle and crypto instance.
|
|
pub fn new(db: Arc<libsql::Database>, crypto: Arc<SecretsCrypto>) -> Self {
|
|
Self { db, crypto }
|
|
}
|
|
|
|
async fn connect(&self) -> Result<libsql::Connection, SecretError> {
|
|
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<Secret, SecretError> {
|
|
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<Secret, SecretError> {
|
|
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<DecryptedSecret, SecretError> {
|
|
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<bool, SecretError> {
|
|
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<Vec<SecretRef>, 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::<String>(0).unwrap_or_default(),
|
|
provider: row.get::<String>(1).ok(),
|
|
});
|
|
}
|
|
Ok(refs)
|
|
}
|
|
|
|
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
|
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<bool, SecretError> {
|
|
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<chrono::DateTime<Utc>, 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<Secret, SecretError> {
|
|
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<u8> = row
|
|
.get(3)
|
|
.map_err(|e| SecretError::Database(e.to_string()))?;
|
|
let key_salt: Vec<u8> = row
|
|
.get(4)
|
|
.map_err(|e| SecretError::Database(e.to_string()))?;
|
|
let provider: Option<String> = row.get::<String>(5).ok().filter(|s| !s.is_empty());
|
|
let expires_at = row
|
|
.get::<String>(6)
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| libsql_parse_timestamp(&s).ok());
|
|
let last_used_at = row
|
|
.get::<String>(7)
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| libsql_parse_timestamp(&s).ok());
|
|
let usage_count: i64 = row.get::<i64>(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<HashMap<(String, String), Secret>>,
|
|
crypto: Arc<SecretsCrypto>,
|
|
}
|
|
|
|
impl InMemorySecretsStore {
|
|
pub fn new(crypto: Arc<SecretsCrypto>) -> Self {
|
|
Self {
|
|
secrets: RwLock::new(HashMap::new()),
|
|
crypto,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SecretsStore for InMemorySecretsStore {
|
|
async fn create(
|
|
&self,
|
|
user_id: &str,
|
|
params: CreateSecretParams,
|
|
) -> Result<Secret, SecretError> {
|
|
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<Secret, SecretError> {
|
|
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<DecryptedSecret, SecretError> {
|
|
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<bool, SecretError> {
|
|
Ok(self
|
|
.secrets
|
|
.read()
|
|
.await
|
|
.contains_key(&(user_id.to_string(), name.to_lowercase())))
|
|
}
|
|
|
|
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, 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<bool, SecretError> {
|
|
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<bool, SecretError> {
|
|
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");
|
|
}
|
|
}
|