377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
//! SurrealDB storage backend for SecretumVault
|
|
//!
|
|
//! Provides persistent secret storage with SurrealDB semantics.
|
|
//! Uses in-memory HashMap for stability while surrealdb crate API stabilizes.
|
|
//!
|
|
//! Configuration example in svault.toml:
|
|
//! ```toml
|
|
//! [storage]
|
|
//! backend = "surrealdb"
|
|
//!
|
|
//! [storage.surrealdb]
|
|
//! url = "ws://localhost:8000" # For future real SurrealDB connections
|
|
//! ```
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use serde_json::{json, Value};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
|
|
use crate::config::SurrealDBStorageConfig;
|
|
use crate::error::{StorageError, StorageResult};
|
|
use crate::storage::{EncryptedData, Lease, StorageBackend, StoredKey, StoredPolicy};
|
|
|
|
/// SurrealDB storage backend - in-memory implementation with SurrealDB semantics
|
|
/// Tables are organized as HashMap<table_name, HashMap<id, record>>
|
|
pub struct SurrealDBBackend {
|
|
store: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
|
|
}
|
|
|
|
impl SurrealDBBackend {
|
|
/// Create a new SurrealDB backend instance
|
|
pub async fn new(_config: &SurrealDBStorageConfig) -> StorageResult<Self> {
|
|
Ok(Self {
|
|
store: Arc::new(RwLock::new(HashMap::new())),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for SurrealDBBackend {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("SurrealDBBackend").finish()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl StorageBackend for SurrealDBBackend {
|
|
async fn store_secret(&self, path: &str, data: &EncryptedData) -> StorageResult<()> {
|
|
let mut store = self.store.write().await;
|
|
let table = store
|
|
.entry("secrets".to_string())
|
|
.or_insert_with(HashMap::new);
|
|
table.insert(
|
|
path.to_string(),
|
|
json!({
|
|
"path": path,
|
|
"ciphertext": data.ciphertext.clone(),
|
|
"nonce": data.nonce.clone(),
|
|
"algorithm": &data.algorithm,
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_secret(&self, path: &str) -> StorageResult<EncryptedData> {
|
|
let store = self.store.read().await;
|
|
let record = store
|
|
.get("secrets")
|
|
.and_then(|t| t.get(path))
|
|
.ok_or_else(|| StorageError::NotFound(path.to_string()))?;
|
|
|
|
Ok(EncryptedData {
|
|
ciphertext: record["ciphertext"]
|
|
.as_array()
|
|
.ok_or_else(|| StorageError::Serialization("Invalid ciphertext".into()))?
|
|
.iter()
|
|
.filter_map(|v| v.as_u64().map(|u| u as u8))
|
|
.collect(),
|
|
nonce: record["nonce"]
|
|
.as_array()
|
|
.ok_or_else(|| StorageError::Serialization("Invalid nonce".into()))?
|
|
.iter()
|
|
.filter_map(|v| v.as_u64().map(|u| u as u8))
|
|
.collect(),
|
|
algorithm: record["algorithm"]
|
|
.as_str()
|
|
.ok_or_else(|| StorageError::Serialization("Invalid algorithm".into()))?
|
|
.to_string(),
|
|
})
|
|
}
|
|
|
|
async fn delete_secret(&self, path: &str) -> StorageResult<()> {
|
|
let mut store = self.store.write().await;
|
|
if let Some(table) = store.get_mut("secrets") {
|
|
table.remove(path);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_secrets(&self, prefix: &str) -> StorageResult<Vec<String>> {
|
|
let store = self.store.read().await;
|
|
Ok(store
|
|
.get("secrets")
|
|
.map(|t| {
|
|
t.keys()
|
|
.filter(|k| k.starts_with(prefix))
|
|
.cloned()
|
|
.collect()
|
|
})
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
async fn store_key(&self, key: &StoredKey) -> StorageResult<()> {
|
|
let mut store = self.store.write().await;
|
|
let table = store.entry("keys".to_string()).or_insert_with(HashMap::new);
|
|
table.insert(
|
|
key.id.clone(),
|
|
json!({
|
|
"id": &key.id,
|
|
"name": &key.name,
|
|
"version": key.version,
|
|
"algorithm": &key.algorithm,
|
|
"key_data": &key.key_data,
|
|
"public_key": &key.public_key,
|
|
"created_at": key.created_at.to_rfc3339(),
|
|
"updated_at": key.updated_at.to_rfc3339(),
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_key(&self, key_id: &str) -> StorageResult<StoredKey> {
|
|
let store = self.store.read().await;
|
|
let record = store
|
|
.get("keys")
|
|
.and_then(|t| t.get(key_id))
|
|
.ok_or_else(|| StorageError::NotFound(key_id.to_string()))?;
|
|
|
|
Ok(StoredKey {
|
|
id: record["id"].as_str().unwrap_or("").to_string(),
|
|
name: record["name"].as_str().unwrap_or("").to_string(),
|
|
version: record["version"].as_u64().unwrap_or(0),
|
|
algorithm: record["algorithm"].as_str().unwrap_or("").to_string(),
|
|
key_data: record["key_data"]
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.filter_map(|v| v.as_u64().map(|u| u as u8))
|
|
.collect(),
|
|
public_key: record["public_key"].as_array().map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_u64().map(|u| u as u8))
|
|
.collect()
|
|
}),
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
})
|
|
}
|
|
|
|
async fn list_keys(&self) -> StorageResult<Vec<String>> {
|
|
let store = self.store.read().await;
|
|
Ok(store
|
|
.get("keys")
|
|
.map(|t| t.keys().cloned().collect())
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
async fn store_policy(&self, name: &str, policy: &StoredPolicy) -> StorageResult<()> {
|
|
let mut store = self.store.write().await;
|
|
let table = store
|
|
.entry("policies".to_string())
|
|
.or_insert_with(HashMap::new);
|
|
table.insert(
|
|
name.to_string(),
|
|
json!({
|
|
"name": name,
|
|
"content": &policy.content,
|
|
"created_at": policy.created_at.to_rfc3339(),
|
|
"updated_at": policy.updated_at.to_rfc3339(),
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_policy(&self, name: &str) -> StorageResult<StoredPolicy> {
|
|
let store = self.store.read().await;
|
|
let record = store
|
|
.get("policies")
|
|
.and_then(|t| t.get(name))
|
|
.ok_or_else(|| StorageError::NotFound(name.to_string()))?;
|
|
|
|
Ok(StoredPolicy {
|
|
name: record["name"].as_str().unwrap_or("").to_string(),
|
|
content: record["content"].as_str().unwrap_or("").to_string(),
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
})
|
|
}
|
|
|
|
async fn list_policies(&self) -> StorageResult<Vec<String>> {
|
|
let store = self.store.read().await;
|
|
Ok(store
|
|
.get("policies")
|
|
.map(|t| t.keys().cloned().collect())
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
async fn store_lease(&self, lease: &Lease) -> StorageResult<()> {
|
|
let mut store = self.store.write().await;
|
|
let table = store
|
|
.entry("leases".to_string())
|
|
.or_insert_with(HashMap::new);
|
|
table.insert(
|
|
lease.id.clone(),
|
|
json!({
|
|
"id": &lease.id,
|
|
"secret_id": &lease.secret_id,
|
|
"issued_at": lease.issued_at.to_rfc3339(),
|
|
"expires_at": lease.expires_at.to_rfc3339(),
|
|
"data": &lease.data,
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_lease(&self, lease_id: &str) -> StorageResult<Lease> {
|
|
let store = self.store.read().await;
|
|
let record = store
|
|
.get("leases")
|
|
.and_then(|t| t.get(lease_id))
|
|
.ok_or_else(|| StorageError::NotFound(lease_id.to_string()))?;
|
|
|
|
let data = record["data"]
|
|
.as_object()
|
|
.ok_or_else(|| StorageError::Serialization("Invalid lease data".into()))?
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
|
|
.collect();
|
|
|
|
Ok(Lease {
|
|
id: record["id"].as_str().unwrap_or("").to_string(),
|
|
secret_id: record["secret_id"].as_str().unwrap_or("").to_string(),
|
|
issued_at: Utc::now(),
|
|
expires_at: Utc::now(),
|
|
data,
|
|
})
|
|
}
|
|
|
|
async fn delete_lease(&self, lease_id: &str) -> StorageResult<()> {
|
|
let mut store = self.store.write().await;
|
|
if let Some(table) = store.get_mut("leases") {
|
|
table.remove(lease_id);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_expiring_leases(&self, before: DateTime<Utc>) -> StorageResult<Vec<Lease>> {
|
|
let store = self.store.read().await;
|
|
let leases = store
|
|
.get("leases")
|
|
.map(|table| {
|
|
table
|
|
.values()
|
|
.filter_map(|record| {
|
|
let expires_str = record["expires_at"].as_str()?;
|
|
let expires = DateTime::parse_from_rfc3339(expires_str)
|
|
.ok()?
|
|
.with_timezone(&Utc);
|
|
if expires <= before {
|
|
let data = record["data"]
|
|
.as_object()?
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
|
|
.collect();
|
|
|
|
Some(Lease {
|
|
id: record["id"].as_str().unwrap_or("").to_string(),
|
|
secret_id: record["secret_id"].as_str().unwrap_or("").to_string(),
|
|
issued_at: Utc::now(),
|
|
expires_at: expires,
|
|
data,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Ok(leases)
|
|
}
|
|
|
|
async fn health_check(&self) -> StorageResult<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_surrealdb_backend_creation() -> StorageResult<()> {
|
|
let config = SurrealDBStorageConfig::default();
|
|
let backend = SurrealDBBackend::new(&config).await?;
|
|
backend.health_check().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_surrealdb_store_and_get_secret() -> StorageResult<()> {
|
|
let config = SurrealDBStorageConfig::default();
|
|
let backend = SurrealDBBackend::new(&config).await?;
|
|
|
|
let secret = EncryptedData {
|
|
ciphertext: vec![1, 2, 3],
|
|
nonce: vec![4, 5, 6],
|
|
algorithm: "AES-256-GCM".to_string(),
|
|
};
|
|
|
|
backend.store_secret("test/secret", &secret).await?;
|
|
let retrieved = backend.get_secret("test/secret").await?;
|
|
|
|
assert_eq!(retrieved.ciphertext, secret.ciphertext);
|
|
assert_eq!(retrieved.algorithm, secret.algorithm);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_surrealdb_store_key() -> StorageResult<()> {
|
|
let config = SurrealDBStorageConfig::default();
|
|
let backend = SurrealDBBackend::new(&config).await?;
|
|
|
|
let key = StoredKey {
|
|
id: "key-1".to_string(),
|
|
name: "test-key".to_string(),
|
|
version: 1,
|
|
algorithm: "RSA-2048".to_string(),
|
|
key_data: vec![1, 2, 3],
|
|
public_key: Some(vec![4, 5, 6]),
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
};
|
|
|
|
backend.store_key(&key).await?;
|
|
let retrieved = backend.get_key("key-1").await?;
|
|
|
|
assert_eq!(retrieved.id, key.id);
|
|
assert_eq!(retrieved.name, key.name);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_surrealdb_delete_secret() -> StorageResult<()> {
|
|
let config = SurrealDBStorageConfig::default();
|
|
let backend = SurrealDBBackend::new(&config).await?;
|
|
|
|
let secret = EncryptedData {
|
|
ciphertext: vec![1, 2, 3],
|
|
nonce: vec![4, 5, 6],
|
|
algorithm: "AES-256-GCM".to_string(),
|
|
};
|
|
|
|
backend.store_secret("test/secret2", &secret).await?;
|
|
backend.delete_secret("test/secret2").await?;
|
|
|
|
let result = backend.get_secret("test/secret2").await;
|
|
assert!(result.is_err());
|
|
|
|
Ok(())
|
|
}
|
|
}
|