fraiseql_secrets/secrets_manager/
mod.rs1use 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#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub enum SecretsBackendConfig {
24 File {
26 path: PathBuf,
28 },
29 Env,
31 Vault {
33 addr: String,
35 auth: VaultAuth,
37 namespace: Option<String>,
39 tls_verify: bool,
41 },
42}
43
44#[derive(Clone)]
50#[non_exhaustive]
51pub enum VaultAuth {
52 Token(Zeroizing<String>),
54 AppRole {
56 role_id: String,
58 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
76pub 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
117pub struct SecretsManager {
119 backend: Arc<dyn SecretsBackend>,
120}
121
122impl SecretsManager {
123 pub fn new(backend: Arc<dyn SecretsBackend>) -> Self {
125 SecretsManager { backend }
126 }
127
128 pub fn backend_name(&self) -> &'static str {
130 self.backend.name()
131 }
132
133 pub async fn health_check(&self) -> Result<(), SecretsError> {
139 self.backend.health_check().await
140 }
141
142 pub async fn get_secret(&self, name: &str) -> Result<String, SecretsError> {
148 self.backend.get_secret(name).await
149 }
150
151 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 pub async fn rotate_secret(&self, name: &str) -> Result<String, SecretsError> {
175 self.backend.rotate_secret(name).await
176 }
177}
178
179pub 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 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 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 #[allow(clippy::cast_possible_wrap)]
244 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#[derive(Debug, Clone)]
262#[non_exhaustive]
263pub enum SecretsError {
264 NotFound(String),
266 BackendError(String),
268 ValidationError(String),
270 EncryptionError(String),
272 RotationError(String),
274 ConnectionError(String),
276 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)] #[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 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 #[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: 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_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 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 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), );
394
395 let handle = tokio::spawn(task.run());
396
397 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: 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 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}