Skip to main content

secrets_core/
reaper.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use chrono::Utc;
5use thiserror::Error;
6
7use crate::engine::EngineError;
8use crate::lease::{self, Lease};
9use crate::router::Router;
10use crate::storage::{StorageBackend, StorageError};
11
12#[derive(Debug, Error)]
13pub enum ReaperError {
14    #[error("storage error: {0}")]
15    Storage(#[from] StorageError),
16    #[error("engine error: {0}")]
17    Engine(#[from] EngineError),
18}
19
20/// Revokes one lease by asking its owning engine mount to `revoke()` it,
21/// then removing the lease record. Best-effort: engine failures are
22/// reported to the caller but the lease record is only deleted on success,
23/// so a failed revoke is retried on the next pass rather than silently lost.
24pub async fn revoke_lease(
25    storage: &dyn StorageBackend,
26    router: &Router,
27    lease: &Lease,
28) -> Result<(), ReaperError> {
29    if let Some((mount, _)) = router.resolve(&lease.engine_mount) {
30        mount.engine.revoke(storage, lease).await?;
31    }
32    lease::delete_lease(storage, lease.id).await?;
33    Ok(())
34}
35
36/// Revokes every lease owned by `token_id_hash` — called when a token is
37/// revoked so its dynamic credentials don't outlive it.
38pub async fn revoke_leases_for_token(
39    storage: &dyn StorageBackend,
40    router: &Router,
41    token_id_hash: &str,
42) -> Result<(), ReaperError> {
43    for lease in lease::list_leases(storage).await? {
44        if lease.token_id_hash == token_id_hash {
45            revoke_lease(storage, router, &lease).await?;
46        }
47    }
48    Ok(())
49}
50
51/// Scans all leases once and revokes any that have expired. Returns the
52/// number successfully reaped; individual failures are logged and skipped
53/// (they'll be retried on the next pass).
54pub async fn reap_once(storage: &dyn StorageBackend, router: &Router) -> Result<usize, ReaperError> {
55    let now = Utc::now();
56    let mut reaped = 0;
57    for lease in lease::list_leases(storage).await? {
58        if lease.expires_at <= now {
59            match revoke_lease(storage, router, &lease).await {
60                Ok(()) => reaped += 1,
61                Err(e) => tracing::warn!(lease_id = %lease.id, error = %e, "failed to reap lease"),
62            }
63        }
64    }
65    Ok(reaped)
66}
67
68/// Spawns a background task that calls `reap_once` on a fixed interval for
69/// the lifetime of the process. Single-node only: a multi-node deployment
70/// would need a Postgres advisory lock here so only one instance reaps at a
71/// time.
72pub fn spawn_reaper(
73    storage: Arc<dyn StorageBackend>,
74    router: Arc<Router>,
75    interval: Duration,
76) -> tokio::task::JoinHandle<()> {
77    tokio::spawn(async move {
78        let mut ticker = tokio::time::interval(interval);
79        loop {
80            ticker.tick().await;
81            match reap_once(storage.as_ref(), router.as_ref()).await {
82                Ok(count) if count > 0 => tracing::info!(count, "reaped expired leases"),
83                Ok(_) => {}
84                Err(e) => tracing::warn!(error = %e, "lease reaper pass failed"),
85            }
86        }
87    })
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::engine::{EngineResult, SecretsEngine};
94    use crate::router::EngineMount;
95    use crate::storage::{StorageEntry, StorageResult};
96    use async_trait::async_trait;
97    use std::collections::HashMap;
98    use std::sync::atomic::{AtomicUsize, Ordering};
99    use std::sync::Mutex;
100    use uuid::Uuid;
101
102    #[derive(Default)]
103    struct MemStorage(Mutex<HashMap<String, StorageEntry>>);
104
105    #[async_trait]
106    impl StorageBackend for MemStorage {
107        async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
108            Ok(self.0.lock().unwrap().get(path).cloned())
109        }
110        async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
111            self.0.lock().unwrap().insert(path.to_string(), entry);
112            Ok(())
113        }
114        async fn delete(&self, path: &str) -> StorageResult<()> {
115            self.0.lock().unwrap().remove(path);
116            Ok(())
117        }
118        async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
119            Ok(self
120                .0
121                .lock()
122                .unwrap()
123                .keys()
124                .filter(|k| k.starts_with(prefix))
125                .cloned()
126                .collect())
127        }
128    }
129
130    #[derive(Default)]
131    struct FakeEngine {
132        revoked: AtomicUsize,
133    }
134
135    #[async_trait]
136    impl SecretsEngine for FakeEngine {
137        async fn read(&self, _storage: &dyn StorageBackend, _path: &str) -> EngineResult<serde_json::Value> {
138            unimplemented!()
139        }
140        async fn write(
141            &self,
142            _storage: &dyn StorageBackend,
143            _path: &str,
144            _data: serde_json::Value,
145        ) -> EngineResult<()> {
146            unimplemented!()
147        }
148        async fn delete(&self, _storage: &dyn StorageBackend, _path: &str) -> EngineResult<()> {
149            unimplemented!()
150        }
151        async fn list(&self, _storage: &dyn StorageBackend, _prefix: &str) -> EngineResult<Vec<String>> {
152            unimplemented!()
153        }
154        async fn revoke(&self, _storage: &dyn StorageBackend, _lease: &Lease) -> EngineResult<()> {
155            self.revoked.fetch_add(1, Ordering::SeqCst);
156            Ok(())
157        }
158    }
159
160    fn lease(token_id_hash: &str, expires_at: chrono::DateTime<Utc>) -> Lease {
161        Lease {
162            id: Uuid::new_v4(),
163            token_id_hash: token_id_hash.to_string(),
164            engine_mount: "database/creds/".to_string(),
165            internal_data: serde_json::json!({}),
166            issued_at: Utc::now(),
167            expires_at,
168        }
169    }
170
171    #[tokio::test]
172    async fn reap_once_revokes_only_expired_leases() {
173        let storage = MemStorage::default();
174        let engine: Arc<dyn SecretsEngine> = Arc::new(FakeEngine::default());
175        let router = Router::new(vec![EngineMount {
176            prefix: "database/creds/".to_string(),
177            engine: engine.clone(),
178        }]);
179
180        let expired = lease("tok", Utc::now() - chrono::Duration::seconds(10));
181        let active = lease("tok", Utc::now() + chrono::Duration::seconds(3600));
182        lease::store_lease(&storage, &expired).await.unwrap();
183        lease::store_lease(&storage, &active).await.unwrap();
184
185        let reaped = reap_once(&storage, &router).await.unwrap();
186        assert_eq!(reaped, 1);
187        assert_eq!(lease::list_leases(&storage).await.unwrap().len(), 1);
188    }
189
190    #[tokio::test]
191    async fn revoke_leases_for_token_cascades() {
192        let storage = MemStorage::default();
193        let engine: Arc<dyn SecretsEngine> = Arc::new(FakeEngine::default());
194        let router = Router::new(vec![EngineMount {
195            prefix: "database/creds/".to_string(),
196            engine,
197        }]);
198
199        let mine = lease("tok-a", Utc::now() + chrono::Duration::seconds(3600));
200        let theirs = lease("tok-b", Utc::now() + chrono::Duration::seconds(3600));
201        lease::store_lease(&storage, &mine).await.unwrap();
202        lease::store_lease(&storage, &theirs).await.unwrap();
203
204        revoke_leases_for_token(&storage, &router, "tok-a").await.unwrap();
205
206        let remaining = lease::list_leases(&storage).await.unwrap();
207        assert_eq!(remaining.len(), 1);
208        assert_eq!(remaining[0].token_id_hash, "tok-b");
209    }
210}