Skip to main content

fraiseql_secrets/secrets_manager/
mod.rs

1//! Abstraction layer for multiple secrets backends (Vault, Environment Variables, File)
2//!
3//! This module provides a unified interface to manage secrets from different sources:
4//! - HashiCorp Vault for dynamic credentials
5//! - Environment variables for configuration
6//! - Local files for development/testing
7
8use std::{fmt, path::PathBuf, sync::Arc, time::Duration};
9
10use chrono::{DateTime, Utc};
11use tracing::{info, warn};
12
13pub mod backends;
14pub mod types;
15
16pub use backends::{EnvBackend, FileBackend, VaultBackend};
17pub use types::{Secret, SecretsBackend};
18
19/// Configuration for selecting and initializing a secrets backend.
20#[derive(Debug, Clone)]
21pub enum SecretsBackendConfig {
22    /// Read secrets from local files (development/testing).
23    File {
24        /// Base directory containing secret files.
25        path: PathBuf,
26    },
27    /// Read secrets from environment variables.
28    Env,
29    /// Read secrets from HashiCorp Vault.
30    Vault {
31        /// Vault server address (e.g., `https://vault.example.com:8200`).
32        addr:       String,
33        /// Authentication method.
34        auth:       VaultAuth,
35        /// Optional namespace (Enterprise feature).
36        namespace:  Option<String>,
37        /// Whether to verify TLS certificates.
38        tls_verify: bool,
39    },
40}
41
42/// Vault authentication methods.
43#[derive(Debug, Clone)]
44pub enum VaultAuth {
45    /// Authenticate with a static token.
46    Token(String),
47    /// Authenticate via AppRole (recommended for production).
48    AppRole {
49        /// The role ID for AppRole login.
50        role_id:   String,
51        /// The secret ID for AppRole login.
52        secret_id: String,
53    },
54}
55
56/// Create a `SecretsManager` from configuration.
57///
58/// # Errors
59///
60/// Returns `SecretsError` if the backend cannot be initialized (e.g., Vault
61/// AppRole login fails).
62pub async fn create_secrets_manager(
63    config: SecretsBackendConfig,
64) -> Result<Arc<SecretsManager>, SecretsError> {
65    let backend: Arc<dyn SecretsBackend> = match config {
66        SecretsBackendConfig::File { path } => {
67            info!(path = %path.display(), "Initializing file secrets backend");
68            Arc::new(FileBackend::new(path))
69        },
70        SecretsBackendConfig::Env => {
71            info!("Initializing environment variable secrets backend");
72            Arc::new(EnvBackend::new())
73        },
74        SecretsBackendConfig::Vault {
75            addr,
76            auth,
77            namespace,
78            tls_verify,
79        } => {
80            info!(addr = %addr, "Initializing Vault secrets backend");
81            let mut vault = match auth {
82                VaultAuth::Token(token) => VaultBackend::new(&addr, &token),
83                VaultAuth::AppRole { role_id, secret_id } => {
84                    VaultBackend::with_approle(&addr, &role_id, &secret_id).await?
85                },
86            };
87            if let Some(ns) = namespace {
88                vault = vault.with_namespace(ns);
89            }
90            vault = vault.with_tls_verify(tls_verify);
91            Arc::new(vault)
92        },
93    };
94    Ok(Arc::new(SecretsManager::new(backend)))
95}
96
97/// Primary secrets manager that caches and rotates credentials.
98pub struct SecretsManager {
99    backend: Arc<dyn SecretsBackend>,
100}
101
102impl SecretsManager {
103    /// Create new `SecretsManager` with specified backend.
104    pub fn new(backend: Arc<dyn SecretsBackend>) -> Self {
105        SecretsManager { backend }
106    }
107
108    /// Get secret by name from backend.
109    pub async fn get_secret(&self, name: &str) -> Result<String, SecretsError> {
110        self.backend.get_secret(name).await
111    }
112
113    /// Get secret with expiry time.
114    ///
115    /// Returns tuple of (secret_value, expiry_datetime).
116    /// Useful for dynamic credentials with lease durations.
117    pub async fn get_secret_with_expiry(
118        &self,
119        name: &str,
120    ) -> Result<(String, DateTime<Utc>), SecretsError> {
121        self.backend.get_secret_with_expiry(name).await
122    }
123
124    /// Rotate secret to new value.
125    ///
126    /// For backends that support it (e.g., Vault), generates new credential.
127    pub async fn rotate_secret(&self, name: &str) -> Result<String, SecretsError> {
128        self.backend.rotate_secret(name).await
129    }
130}
131
132/// Background task that renews expiring Vault leases.
133///
134/// Periodically checks cached secrets and refreshes those approaching expiry
135/// (within 20% of their original TTL). Designed to run as a background tokio task.
136pub struct LeaseRenewalTask {
137    manager:        Arc<SecretsManager>,
138    check_interval: Duration,
139    cancel_rx:      tokio::sync::watch::Receiver<bool>,
140    tracked_keys:   Vec<String>,
141}
142
143impl LeaseRenewalTask {
144    /// Create a new lease renewal task.
145    ///
146    /// Returns the task and a sender to trigger cancellation (send `true` to stop).
147    pub fn new(
148        manager: Arc<SecretsManager>,
149        tracked_keys: Vec<String>,
150        check_interval: Duration,
151    ) -> (Self, tokio::sync::watch::Sender<bool>) {
152        let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
153        (
154            Self {
155                manager,
156                check_interval,
157                cancel_rx,
158                tracked_keys,
159            },
160            cancel_tx,
161        )
162    }
163
164    /// Run the lease renewal loop.
165    ///
166    /// Blocks until the cancel sender sends `true` or is dropped.
167    pub async fn run(mut self) {
168        info!(
169            interval_secs = self.check_interval.as_secs(),
170            keys = self.tracked_keys.len(),
171            "Lease renewal task started"
172        );
173        loop {
174            tokio::select! {
175                result = self.cancel_rx.changed() => {
176                    if result.is_err() || *self.cancel_rx.borrow() {
177                        info!("Lease renewal task stopped");
178                        break;
179                    }
180                },
181                () = tokio::time::sleep(self.check_interval) => {
182                    self.renew_expiring_leases().await;
183                }
184            }
185        }
186    }
187
188    async fn renew_expiring_leases(&self) {
189        for key in &self.tracked_keys {
190            match self.manager.get_secret_with_expiry(key).await {
191                Ok((_, expiry)) => {
192                    let remaining = expiry - Utc::now();
193                    // Refresh if less than 20% of the check interval remains
194                    if remaining
195                        < chrono::Duration::seconds(
196                            (self.check_interval.as_secs() as f64 * 0.2) as i64,
197                        )
198                    {
199                        match self.manager.rotate_secret(key).await {
200                            Ok(_) => info!(key = %key, "Lease renewed"),
201                            Err(e) => warn!(key = %key, error = %e, "Lease renewal failed"),
202                        }
203                    }
204                },
205                Err(e) => {
206                    warn!(key = %key, error = %e, "Failed to check lease expiry");
207                },
208            }
209        }
210    }
211}
212
213/// Error type for secrets operations.
214#[derive(Debug, Clone)]
215pub enum SecretsError {
216    /// Secret not found in the backend.
217    NotFound(String),
218    /// Backend communication or configuration error.
219    BackendError(String),
220    /// Invalid input (e.g., bad secret name format).
221    ValidationError(String),
222    /// Encryption or decryption failure.
223    EncryptionError(String),
224    /// Rotation not supported or failed.
225    RotationError(String),
226    /// Connection error (e.g., Vault unreachable).
227    ConnectionError(String),
228    /// Credential has expired.
229    ExpiredCredential,
230}
231
232impl fmt::Display for SecretsError {
233    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
234        match self {
235            SecretsError::NotFound(msg) => write!(f, "Secret not found: {msg}"),
236            SecretsError::BackendError(msg) => write!(f, "Backend error: {msg}"),
237            SecretsError::ValidationError(msg) => write!(f, "Validation error: {msg}"),
238            SecretsError::EncryptionError(msg) => write!(f, "Encryption error: {msg}"),
239            SecretsError::RotationError(msg) => write!(f, "Rotation error: {msg}"),
240            SecretsError::ConnectionError(msg) => write!(f, "Connection error: {msg}"),
241            SecretsError::ExpiredCredential => write!(f, "Credential expired"),
242        }
243    }
244}
245
246impl std::error::Error for SecretsError {}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[tokio::test]
253    async fn test_create_secrets_manager_file_backend() {
254        let dir = tempfile::tempdir().unwrap();
255        let secret_path = dir.path().join("db_password");
256        tokio::fs::write(&secret_path, "s3cret").await.unwrap();
257
258        let manager = create_secrets_manager(SecretsBackendConfig::File {
259            path: dir.path().to_path_buf(),
260        })
261        .await
262        .unwrap();
263
264        let value = manager.get_secret("db_password").await.unwrap();
265        assert_eq!(value, "s3cret");
266    }
267
268    #[tokio::test]
269    async fn test_create_secrets_manager_env_backend() {
270        // Use a unique env var to avoid test interference
271        let key = "FRAISEQL_TEST_SM_SECRET_FACTORY";
272        temp_env::async_with_vars([(key, Some("env_value"))], async {
273            let manager = create_secrets_manager(SecretsBackendConfig::Env).await.unwrap();
274            let value = manager.get_secret(key).await.unwrap();
275            assert_eq!(value, "env_value");
276        })
277        .await;
278    }
279}