use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type SecretResult<T> = Result<T, SecretError>;
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
#[error("Secret not found: {name}")]
NotFound { name: String },
#[error("Access denied to secret: {name}")]
AccessDenied { name: String },
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Connection error: {0}")]
Connection(String),
#[error("Authentication error: {0}")]
Authentication(String),
#[error("Invalid secret format: {0}")]
InvalidFormat(String),
#[error("Secret expired: {name}")]
Expired { name: String },
#[error("Rate limited: retry after {retry_after_ms}ms")]
RateLimited { retry_after_ms: u64 },
#[error("Secret manager error: {0}")]
Other(String),
}
impl SecretError {
pub fn not_found(name: impl Into<String>) -> Self {
Self::NotFound { name: name.into() }
}
pub fn access_denied(name: impl Into<String>) -> Self {
Self::AccessDenied { name: name.into() }
}
pub fn config(msg: impl Into<String>) -> Self {
Self::Configuration(msg.into())
}
pub fn connection(msg: impl Into<String>) -> Self {
Self::Connection(msg.into())
}
pub fn auth(msg: impl Into<String>) -> Self {
Self::Authentication(msg.into())
}
pub fn invalid_format(msg: impl Into<String>) -> Self {
Self::InvalidFormat(msg.into())
}
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretMetadata {
pub name: String,
pub version: Option<String>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
pub expires_at: Option<i64>,
pub tags: HashMap<String, String>,
}
impl SecretMetadata {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
version: None,
created_at: None,
updated_at: None,
expires_at: None,
tags: HashMap::new(),
}
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.tags.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone)]
pub struct Secret {
pub value: String,
pub metadata: Option<SecretMetadata>,
}
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
metadata: None,
}
}
pub fn with_metadata(mut self, metadata: SecretMetadata) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ReadSecretOptions {
pub version: Option<String>,
pub include_metadata: bool,
}
impl ReadSecretOptions {
pub fn new() -> Self {
Self::default()
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_metadata(mut self) -> Self {
self.include_metadata = true;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct WriteSecretOptions {
pub description: Option<String>,
pub tags: HashMap<String, String>,
pub overwrite: bool,
}
impl WriteSecretOptions {
pub fn new() -> Self {
Self::default()
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.tags.insert(key.into(), value.into());
self
}
pub fn overwrite(mut self, overwrite: bool) -> Self {
self.overwrite = overwrite;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ListSecretsOptions {
pub prefix: Option<String>,
pub max_results: Option<usize>,
pub next_token: Option<String>,
}
impl ListSecretsOptions {
pub fn new() -> Self {
Self::default()
}
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = Some(prefix.into());
self
}
pub fn max_results(mut self, max: usize) -> Self {
self.max_results = Some(max);
self
}
pub fn next_token(mut self, token: impl Into<String>) -> Self {
self.next_token = Some(token.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ListSecretsResult {
pub secrets: Vec<SecretMetadata>,
pub next_token: Option<String>,
}
#[async_trait]
pub trait SecretManager: Send + Sync {
fn name(&self) -> &'static str;
async fn read_secret(&self, name: &str) -> SecretResult<Option<String>>;
async fn read_secret_with_options(
&self,
name: &str,
options: &ReadSecretOptions,
) -> SecretResult<Option<Secret>> {
let _ = options;
match self.read_secret(name).await? {
Some(value) => Ok(Some(Secret::new(value))),
None => Ok(None),
}
}
async fn write_secret(&self, name: &str, value: &str) -> SecretResult<()>;
async fn write_secret_with_options(
&self,
name: &str,
value: &str,
options: &WriteSecretOptions,
) -> SecretResult<()> {
let _ = options;
self.write_secret(name, value).await
}
async fn delete_secret(&self, name: &str) -> SecretResult<()>;
async fn rotate_secret(&self, name: &str, new_value: &str) -> SecretResult<()> {
self.write_secret(name, new_value).await
}
async fn list_secrets(&self, options: &ListSecretsOptions) -> SecretResult<ListSecretsResult>;
async fn exists(&self, name: &str) -> SecretResult<bool> {
Ok(self.read_secret(name).await?.is_some())
}
}
pub type BoxedSecretManager = std::sync::Arc<dyn SecretManager>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_error_constructors() {
let err = SecretError::not_found("my-secret");
assert!(matches!(err, SecretError::NotFound { .. }));
assert!(err.to_string().contains("my-secret"));
let err = SecretError::access_denied("restricted-secret");
assert!(matches!(err, SecretError::AccessDenied { .. }));
let err = SecretError::config("Invalid configuration");
assert!(matches!(err, SecretError::Configuration(_)));
let err = SecretError::connection("Connection refused");
assert!(matches!(err, SecretError::Connection(_)));
let err = SecretError::auth("Invalid credentials");
assert!(matches!(err, SecretError::Authentication(_)));
}
#[test]
fn test_secret_metadata_builder() {
let metadata = SecretMetadata::new("api-key")
.version("v1")
.tag("env", "production")
.tag("team", "platform");
assert_eq!(metadata.name, "api-key");
assert_eq!(metadata.version, Some("v1".to_string()));
assert_eq!(metadata.tags.get("env"), Some(&"production".to_string()));
assert_eq!(metadata.tags.get("team"), Some(&"platform".to_string()));
}
#[test]
fn test_secret_builder() {
let secret = Secret::new("super-secret-value")
.with_metadata(SecretMetadata::new("my-secret").version("v2"));
assert_eq!(secret.value, "super-secret-value");
assert!(secret.metadata.is_some());
assert_eq!(secret.metadata.unwrap().version, Some("v2".to_string()));
}
#[test]
fn test_read_secret_options() {
let options = ReadSecretOptions::new().version("v3").with_metadata();
assert_eq!(options.version, Some("v3".to_string()));
assert!(options.include_metadata);
}
#[test]
fn test_write_secret_options() {
let options = WriteSecretOptions::new()
.description("API key for external service")
.tag("service", "stripe")
.overwrite(true);
assert_eq!(
options.description,
Some("API key for external service".to_string())
);
assert_eq!(options.tags.get("service"), Some(&"stripe".to_string()));
assert!(options.overwrite);
}
#[test]
fn test_list_secrets_options() {
let options = ListSecretsOptions::new()
.prefix("prod/")
.max_results(100)
.next_token("abc123");
assert_eq!(options.prefix, Some("prod/".to_string()));
assert_eq!(options.max_results, Some(100));
assert_eq!(options.next_token, Some("abc123".to_string()));
}
}