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};
12use zeroize::Zeroizing;
13
14pub mod backends;
15pub mod types;
16
17pub use backends::{EnvBackend, FileBackend, VaultBackend};
18pub use types::{Secret, SecretsBackend};
19
20/// Configuration for selecting and initializing a secrets backend.
21#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub enum SecretsBackendConfig {
24    /// Read secrets from local files (development/testing).
25    File {
26        /// Base directory containing secret files.
27        path: PathBuf,
28    },
29    /// Read secrets from environment variables.
30    Env,
31    /// Read secrets from `HashiCorp` Vault.
32    Vault {
33        /// Vault server address (e.g., `https://vault.example.com:8200`).
34        addr:       String,
35        /// Authentication method.
36        auth:       VaultAuth,
37        /// Optional namespace (Enterprise feature).
38        namespace:  Option<String>,
39        /// Whether to verify TLS certificates.
40        tls_verify: bool,
41    },
42}
43
44/// Vault authentication methods.
45///
46/// Sensitive fields (`Token` payload and `secret_id`) are wrapped in
47/// [`Zeroizing`] so that the credential bytes are overwritten on drop rather
48/// than remaining in heap until the allocator reuses the memory.
49#[derive(Clone)]
50#[non_exhaustive]
51pub enum VaultAuth {
52    /// Authenticate with a static token.
53    Token(Zeroizing<String>),
54    /// Authenticate via `AppRole` (recommended for production).
55    AppRole {
56        /// The role ID for `AppRole` login.
57        role_id:   String,
58        /// The secret ID for `AppRole` login (high-value credential).
59        secret_id: Zeroizing<String>,
60    },
61}
62
63impl fmt::Debug for VaultAuth {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Token(_) => f.debug_tuple("Token").field(&"[REDACTED]").finish(),
67            Self::AppRole { role_id, .. } => f
68                .debug_struct("AppRole")
69                .field("role_id", role_id)
70                .field("secret_id", &"[REDACTED]")
71                .finish(),
72        }
73    }
74}
75
76/// Create a `SecretsManager` from configuration.
77///
78/// # Errors
79///
80/// Returns `SecretsError` if the backend cannot be initialized (e.g., Vault
81/// `AppRole` login fails).
82pub async fn create_secrets_manager(
83    config: SecretsBackendConfig,
84) -> Result<Arc<SecretsManager>, SecretsError> {
85    let backend: Arc<dyn SecretsBackend> = match config {
86        SecretsBackendConfig::File { path } => {
87            info!(path = %path.display(), "Initializing file secrets backend");
88            Arc::new(FileBackend::new(path))
89        },
90        SecretsBackendConfig::Env => {
91            info!("Initializing environment variable secrets backend");
92            Arc::new(EnvBackend::new())
93        },
94        SecretsBackendConfig::Vault {
95            addr,
96            auth,
97            namespace,
98            tls_verify,
99        } => {
100            info!(addr = %addr, "Initializing Vault secrets backend");
101            let mut vault = match auth {
102                VaultAuth::Token(token) => VaultBackend::new(addr.as_str(), token.as_str())?,
103                VaultAuth::AppRole { role_id, secret_id } => {
104                    VaultBackend::with_approle(&addr, &role_id, secret_id.as_str()).await?
105                },
106            };
107            if let Some(ns) = namespace {
108                vault = vault.with_namespace(ns);
109            }
110            vault = vault.with_tls_verify(tls_verify);
111            Arc::new(vault)
112        },
113    };
114    Ok(Arc::new(SecretsManager::new(backend)))
115}
116
117/// Primary secrets manager that caches and rotates credentials.
118pub struct SecretsManager {
119    backend: Arc<dyn SecretsBackend>,
120}
121
122impl SecretsManager {
123    /// Create new `SecretsManager` with specified backend.
124    pub fn new(backend: Arc<dyn SecretsBackend>) -> Self {
125        SecretsManager { backend }
126    }
127
128    /// Returns the backend type name (e.g., `"vault"`, `"env"`, `"file"`).
129    pub fn backend_name(&self) -> &'static str {
130        self.backend.name()
131    }
132
133    /// Performs a lightweight connectivity check on the underlying backend.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SecretsError`] if the backend is unreachable or returns an error.
138    pub async fn health_check(&self) -> Result<(), SecretsError> {
139        self.backend.health_check().await
140    }
141
142    /// Get secret by name from backend.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`SecretsError`] if the secret does not exist or the backend returns an error.
147    pub async fn get_secret(&self, name: &str) -> Result<String, SecretsError> {
148        self.backend.get_secret(name).await
149    }
150
151    /// Get secret with expiry time.
152    ///
153    /// Returns tuple of (`secret_value`, `expiry_datetime`).
154    /// Useful for dynamic credentials with lease durations.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`SecretsError`] if the secret does not exist or the backend returns an error.
159    pub async fn get_secret_with_expiry(
160        &self,
161        name: &str,
162    ) -> Result<(String, DateTime<Utc>), SecretsError> {
163        self.backend.get_secret_with_expiry(name).await
164    }
165
166    /// Rotate secret to new value.
167    ///
168    /// For backends that support it (e.g., Vault), generates new credential.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`SecretsError`] if rotation is unsupported by the backend or the backend returns an
173    /// error.
174    pub async fn rotate_secret(&self, name: &str) -> Result<String, SecretsError> {
175        self.backend.rotate_secret(name).await
176    }
177}
178
179/// Background task that renews expiring Vault leases.
180///
181/// Periodically checks cached secrets and refreshes those approaching expiry
182/// (within one `check_interval` of expiry, ensuring renewal before the next
183/// poll cycle can catch it). Designed to run as a background tokio task.
184pub struct LeaseRenewalTask {
185    manager:        Arc<SecretsManager>,
186    check_interval: Duration,
187    cancel_rx:      tokio::sync::watch::Receiver<bool>,
188    tracked_keys:   Vec<String>,
189}
190
191impl LeaseRenewalTask {
192    /// Create a new lease renewal task.
193    ///
194    /// Returns the task and a sender to trigger cancellation (send `true` to stop).
195    pub fn new(
196        manager: Arc<SecretsManager>,
197        tracked_keys: Vec<String>,
198        check_interval: Duration,
199    ) -> (Self, tokio::sync::watch::Sender<bool>) {
200        let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
201        (
202            Self {
203                manager,
204                check_interval,
205                cancel_rx,
206                tracked_keys,
207            },
208            cancel_tx,
209        )
210    }
211
212    /// Run the lease renewal loop.
213    ///
214    /// Blocks until the cancel sender sends `true` or is dropped.
215    pub async fn run(mut self) {
216        info!(
217            interval_secs = self.check_interval.as_secs(),
218            keys = self.tracked_keys.len(),
219            "Lease renewal task started"
220        );
221        loop {
222            tokio::select! {
223                result = self.cancel_rx.changed() => {
224                    if result.is_err() || *self.cancel_rx.borrow() {
225                        info!("Lease renewal task stopped");
226                        break;
227                    }
228                },
229                () = tokio::time::sleep(self.check_interval) => {
230                    self.renew_expiring_leases().await;
231                }
232            }
233        }
234    }
235
236    async fn renew_expiring_leases(&self) {
237        for key in &self.tracked_keys {
238            match self.manager.get_secret_with_expiry(key).await {
239                Ok((_, expiry)) => {
240                    let remaining = expiry - Utc::now();
241                    // Refresh if less than one full check interval remains,
242                    // ensuring renewal completes before the next poll would be too late.
243                    #[allow(clippy::cast_possible_wrap)]
244                    // Reason: check_interval is always small (seconds), never exceeds i64::MAX
245                    if remaining < chrono::Duration::seconds(self.check_interval.as_secs() as i64) {
246                        match self.manager.rotate_secret(key).await {
247                            Ok(_) => info!(key = %key, "Lease renewed"),
248                            Err(e) => warn!(key = %key, error = %e, "Lease renewal failed"),
249                        }
250                    }
251                },
252                Err(e) => {
253                    warn!(key = %key, error = %e, "Failed to check lease expiry");
254                },
255            }
256        }
257    }
258}
259
260/// Error type for secrets operations.
261#[derive(Debug, Clone)]
262#[non_exhaustive]
263pub enum SecretsError {
264    /// Secret not found in the backend.
265    NotFound(String),
266    /// Backend communication or configuration error.
267    BackendError(String),
268    /// Invalid input (e.g., bad secret name format).
269    ValidationError(String),
270    /// Encryption or decryption failure.
271    EncryptionError(String),
272    /// Rotation not supported or failed.
273    RotationError(String),
274    /// Connection error (e.g., Vault unreachable).
275    ConnectionError(String),
276    /// Credential has expired.
277    ExpiredCredential,
278}
279
280impl fmt::Display for SecretsError {
281    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
282        match self {
283            SecretsError::NotFound(msg) => write!(f, "Secret not found: {msg}"),
284            SecretsError::BackendError(msg) => write!(f, "Backend error: {msg}"),
285            SecretsError::ValidationError(msg) => write!(f, "Validation error: {msg}"),
286            SecretsError::EncryptionError(msg) => write!(f, "Encryption error: {msg}"),
287            SecretsError::RotationError(msg) => write!(f, "Rotation error: {msg}"),
288            SecretsError::ConnectionError(msg) => write!(f, "Connection error: {msg}"),
289            SecretsError::ExpiredCredential => write!(f, "Credential expired"),
290        }
291    }
292}
293
294impl std::error::Error for SecretsError {}
295
296#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
297#[cfg(test)]
298mod tests {
299    use std::{
300        sync::atomic::{AtomicUsize, Ordering},
301        time::Duration,
302    };
303
304    use chrono::Utc;
305
306    use super::*;
307
308    // ---------------------------------------------------------------------------
309    // Mock SecretsBackend for LeaseRenewalTask tests
310    // ---------------------------------------------------------------------------
311
312    /// A mock backend that returns a fixed secret with configurable expiry.
313    /// Rotation calls are counted so tests can assert how many renewals occurred.
314    struct MockBackend {
315        secret:       String,
316        expiry:       DateTime<Utc>,
317        rotate_count: Arc<AtomicUsize>,
318    }
319
320    #[async_trait::async_trait]
321    impl SecretsBackend for MockBackend {
322        fn name(&self) -> &'static str {
323            "mock"
324        }
325
326        async fn health_check(&self) -> Result<(), SecretsError> {
327            Ok(())
328        }
329
330        async fn get_secret(&self, _name: &str) -> Result<String, SecretsError> {
331            Ok(self.secret.clone())
332        }
333
334        async fn get_secret_with_expiry(
335            &self,
336            _name: &str,
337        ) -> Result<(String, DateTime<Utc>), SecretsError> {
338            Ok((self.secret.clone(), self.expiry))
339        }
340
341        async fn rotate_secret(&self, _name: &str) -> Result<String, SecretsError> {
342            self.rotate_count.fetch_add(1, Ordering::SeqCst);
343            Ok("rotated".to_string())
344        }
345    }
346
347    // ---------------------------------------------------------------------------
348    // LeaseRenewalTask tests
349    // ---------------------------------------------------------------------------
350
351    #[tokio::test]
352    async fn test_lease_renewal_task_cancels_cleanly() {
353        let rotate_count = Arc::new(AtomicUsize::new(0));
354        let backend = MockBackend {
355            secret:       "s3cret".to_string(),
356            // Expiry far in the future — no renewal needed.
357            expiry:       Utc::now() + chrono::Duration::hours(1),
358            rotate_count: Arc::clone(&rotate_count),
359        };
360        let manager = Arc::new(SecretsManager::new(Arc::new(backend)));
361
362        let (task, cancel_tx) =
363            LeaseRenewalTask::new(manager, vec!["db/creds".to_string()], Duration::from_secs(60));
364
365        // Cancel immediately before the first sleep interval fires.
366        cancel_tx.send(true).unwrap();
367        tokio::time::timeout(Duration::from_secs(2), task.run())
368            .await
369            .expect("task should exit quickly after cancellation");
370
371        // No renewals should have occurred since we cancelled before the first tick.
372        assert_eq!(rotate_count.load(Ordering::SeqCst), 0);
373    }
374
375    #[tokio::test]
376    async fn test_lease_renewal_triggers_rotate_when_expiry_near() {
377        let rotate_count = Arc::new(AtomicUsize::new(0));
378        let backend = MockBackend {
379            secret:       "s3cret".to_string(),
380            // Already-expired credential: remaining is negative, which is always
381            // less than the check_interval threshold, so renewal fires on every tick.
382            // This works with any sub-second check_interval (where as_secs() == 0)
383            // because negative < zero is true for chrono::Duration.
384            expiry:       Utc::now() - chrono::Duration::seconds(1),
385            rotate_count: Arc::clone(&rotate_count),
386        };
387        let manager = Arc::new(SecretsManager::new(Arc::new(backend)));
388
389        let (task, cancel_tx) = LeaseRenewalTask::new(
390            manager,
391            vec!["db/creds".to_string()],
392            Duration::from_millis(50), // very short interval so the test is fast
393        );
394
395        let handle = tokio::spawn(task.run());
396
397        // Wait long enough for at least one tick to fire.
398        tokio::time::sleep(Duration::from_millis(200)).await;
399        cancel_tx.send(true).unwrap();
400        tokio::time::timeout(Duration::from_secs(2), handle)
401            .await
402            .expect("task should exit quickly after cancellation")
403            .unwrap();
404
405        assert!(
406            rotate_count.load(Ordering::SeqCst) >= 1,
407            "expected at least one renewal for an expired credential"
408        );
409    }
410
411    #[tokio::test]
412    async fn test_lease_renewal_skips_non_expiring_keys() {
413        let rotate_count = Arc::new(AtomicUsize::new(0));
414        let backend = MockBackend {
415            secret:       "s3cret".to_string(),
416            // Expiry 1 hour away — much longer than the check interval (50 ms).
417            expiry:       Utc::now() + chrono::Duration::hours(1),
418            rotate_count: Arc::clone(&rotate_count),
419        };
420        let manager = Arc::new(SecretsManager::new(Arc::new(backend)));
421
422        let (task, cancel_tx) =
423            LeaseRenewalTask::new(manager, vec!["db/creds".to_string()], Duration::from_millis(50));
424
425        let handle = tokio::spawn(task.run());
426        tokio::time::sleep(Duration::from_millis(200)).await;
427        cancel_tx.send(true).unwrap();
428        tokio::time::timeout(Duration::from_secs(2), handle)
429            .await
430            .expect("task should exit quickly")
431            .unwrap();
432
433        assert_eq!(
434            rotate_count.load(Ordering::SeqCst),
435            0,
436            "credentials with distant expiry should not be rotated"
437        );
438    }
439
440    #[tokio::test]
441    async fn test_create_secrets_manager_file_backend() {
442        let dir = tempfile::tempdir().unwrap();
443        let secret_path = dir.path().join("db_password");
444        tokio::fs::write(&secret_path, "s3cret").await.unwrap();
445
446        let manager = create_secrets_manager(SecretsBackendConfig::File {
447            path: dir.path().to_path_buf(),
448        })
449        .await
450        .unwrap();
451
452        let value = manager.get_secret("db_password").await.unwrap();
453        assert_eq!(value, "s3cret");
454    }
455
456    #[tokio::test]
457    async fn test_create_secrets_manager_env_backend() {
458        // Use a unique env var to avoid test interference
459        let key = "FRAISEQL_TEST_SM_SECRET_FACTORY";
460        temp_env::async_with_vars([(key, Some("env_value"))], async {
461            let manager = create_secrets_manager(SecretsBackendConfig::Env).await.unwrap();
462            let value = manager.get_secret(key).await.unwrap();
463            assert_eq!(value, "env_value");
464        })
465        .await;
466    }
467}