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_expired_leases(storage, now).await? {
58        match revoke_lease(storage, router, &lease).await {
59            Ok(()) => reaped += 1,
60            Err(e) => tracing::warn!(lease_id = %lease.id, error = %e, "failed to reap lease"),
61        }
62    }
63    Ok(reaped)
64}
65
66/// Elects the single replica allowed to reap. Every node competes for this
67/// one name, so the value matters only in that it must be identical
68/// everywhere.
69pub const REAPER_LOCK: &str = "secrets/lease-reaper";
70
71/// Runs one reap pass, but only on the replica that holds the reaper lock.
72/// Returns `None` on a standby node, which has nothing to do.
73///
74/// Without this, every replica would scan the same leases and revoke each one
75/// concurrently — mostly harmless, since engines treat an already-revoked
76/// credential as success, but it multiplies provider API calls by the replica
77/// count and races on engines that cannot.
78pub async fn reap_once_if_leader(
79    storage: &dyn StorageBackend,
80    router: &Router,
81) -> Result<Option<usize>, ReaperError> {
82    if !storage.try_acquire_lock(REAPER_LOCK).await? {
83        return Ok(None);
84    }
85    reap_once(storage, router).await.map(Some)
86}
87
88/// Spawns a background task that reaps on a fixed interval for the lifetime of
89/// the process, on whichever replica wins the lock.
90///
91/// The lock is taken once and held, rather than passed around each tick: a
92/// stable leader avoids re-contending every interval, and because the lock
93/// lives on a held connection, a crashed leader releases it automatically and
94/// the next tick elsewhere picks the work up.
95pub fn spawn_reaper(
96    storage: Arc<dyn StorageBackend>,
97    router: Arc<Router>,
98    interval: Duration,
99) -> tokio::task::JoinHandle<()> {
100    tokio::spawn(async move {
101        let mut ticker = tokio::time::interval(interval);
102        let mut announced_standby = false;
103        loop {
104            ticker.tick().await;
105            match reap_once_if_leader(storage.as_ref(), router.as_ref()).await {
106                Ok(Some(count)) if count > 0 => {
107                    tracing::info!(count, "reaped expired leases")
108                }
109                Ok(Some(_)) => {}
110                // Logged once, not every tick: on a multi-replica deployment
111                // most nodes are standbys for their whole lifetime.
112                Ok(None) => {
113                    if !announced_standby {
114                        announced_standby = true;
115                        tracing::info!(
116                            lock = REAPER_LOCK,
117                            "another replica holds the lease-reaper lock; standing by"
118                        );
119                    }
120                }
121                Err(e) => tracing::warn!(error = %e, "lease reaper pass failed"),
122            }
123        }
124    })
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::engine::{EngineResult, SecretsEngine};
131    use crate::router::EngineMount;
132    use crate::storage::{StorageEntry, StorageResult};
133    use async_trait::async_trait;
134    use std::collections::HashMap;
135    use std::sync::atomic::{AtomicUsize, Ordering};
136    use std::sync::Mutex;
137    use uuid::Uuid;
138
139    #[derive(Default)]
140    struct MemStorage(Mutex<HashMap<String, StorageEntry>>);
141
142    #[async_trait]
143    impl StorageBackend for MemStorage {
144        async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
145            Ok(self.0.lock().unwrap().get(path).cloned())
146        }
147        async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
148            self.0.lock().unwrap().insert(path.to_string(), entry);
149            Ok(())
150        }
151        async fn delete(&self, path: &str) -> StorageResult<()> {
152            self.0.lock().unwrap().remove(path);
153            Ok(())
154        }
155        async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
156            Ok(self
157                .0
158                .lock()
159                .unwrap()
160                .keys()
161                .filter(|k| k.starts_with(prefix))
162                .cloned()
163                .collect())
164        }
165    }
166
167    #[derive(Default)]
168    struct FakeEngine {
169        revoked: AtomicUsize,
170    }
171
172    #[async_trait]
173    impl SecretsEngine for FakeEngine {
174        fn doc(&self) -> crate::engine::EngineDoc {
175            crate::engine::EngineDoc {
176                provider: "fake".to_string(),
177                mechanism: "test double".to_string(),
178                shape: crate::engine::CredentialShape::MintAndRevoke,
179                revocable: true,
180                revoke_effect: "counts the call".to_string(),
181                ttl: crate::engine::TtlDoc::fixed(60, "test"),
182                scoping: "none".to_string(),
183                root_credential: "none".to_string(),
184                paths: vec![],
185                docs_url: None,
186                caveats: vec![],
187            }
188        }
189        async fn read(&self, _storage: &dyn StorageBackend, _path: &str) -> EngineResult<serde_json::Value> {
190            unimplemented!()
191        }
192        async fn write(
193            &self,
194            _storage: &dyn StorageBackend,
195            _path: &str,
196            _data: serde_json::Value,
197        ) -> EngineResult<()> {
198            unimplemented!()
199        }
200        async fn delete(&self, _storage: &dyn StorageBackend, _path: &str) -> EngineResult<()> {
201            unimplemented!()
202        }
203        async fn list(&self, _storage: &dyn StorageBackend, _prefix: &str) -> EngineResult<Vec<String>> {
204            unimplemented!()
205        }
206        async fn revoke(&self, _storage: &dyn StorageBackend, _lease: &Lease) -> EngineResult<()> {
207            self.revoked.fetch_add(1, Ordering::SeqCst);
208            Ok(())
209        }
210    }
211
212    fn lease(token_id_hash: &str, expires_at: chrono::DateTime<Utc>) -> Lease {
213        Lease {
214            id: Uuid::new_v4(),
215            token_id_hash: token_id_hash.to_string(),
216            engine_mount: "database/creds/".to_string(),
217            internal_data: serde_json::json!({}),
218            issued_at: Utc::now(),
219            expires_at,
220        }
221    }
222
223    /// Stands in for a replica that lost the reaper election: it stores
224    /// normally but never wins the lock.
225    struct Standby<B>(B);
226
227    #[async_trait]
228    impl<B: StorageBackend> StorageBackend for Standby<B> {
229        async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
230            self.0.get(path).await
231        }
232        async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
233            self.0.put(path, entry).await
234        }
235        async fn delete(&self, path: &str) -> StorageResult<()> {
236            self.0.delete(path).await
237        }
238        async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
239            self.0.list(prefix).await
240        }
241        async fn try_acquire_lock(&self, _key: &str) -> StorageResult<bool> {
242            Ok(false)
243        }
244    }
245
246    /// The whole point of the lock: a standby must not touch the provider.
247    /// Without this, every replica revokes every expired lease.
248    #[tokio::test]
249    async fn a_standby_replica_does_not_reap() {
250        let storage = Standby(MemStorage::default());
251        let engine = Arc::new(FakeEngine::default());
252        let router = Router::new(vec![EngineMount {
253            prefix: "database/creds/".to_string(),
254            engine: engine.clone() as Arc<dyn SecretsEngine>,
255        }]);
256
257        let expired = lease("tok", Utc::now() - chrono::Duration::seconds(10));
258        lease::store_lease(&storage, &expired).await.unwrap();
259
260        let outcome = reap_once_if_leader(&storage, &router).await.unwrap();
261        assert!(outcome.is_none(), "standby reported a reap pass");
262        assert_eq!(
263            engine.revoked.load(Ordering::SeqCst),
264            0,
265            "standby called the provider anyway"
266        );
267        assert_eq!(
268            lease::list_leases(&storage).await.unwrap().len(),
269            1,
270            "standby deleted a lease it does not own"
271        );
272    }
273
274    /// The default `try_acquire_lock` grants unconditionally, so a single-node
275    /// deployment keeps reaping with no lock support in its backend.
276    #[tokio::test]
277    async fn the_leader_reaps() {
278        let storage = MemStorage::default();
279        let engine = Arc::new(FakeEngine::default());
280        let router = Router::new(vec![EngineMount {
281            prefix: "database/creds/".to_string(),
282            engine: engine.clone() as Arc<dyn SecretsEngine>,
283        }]);
284
285        let expired = lease("tok", Utc::now() - chrono::Duration::seconds(10));
286        lease::store_lease(&storage, &expired).await.unwrap();
287
288        assert_eq!(reap_once_if_leader(&storage, &router).await.unwrap(), Some(1));
289        assert_eq!(engine.revoked.load(Ordering::SeqCst), 1);
290    }
291
292    #[tokio::test]
293    async fn reap_once_revokes_only_expired_leases() {
294        let storage = MemStorage::default();
295        let engine: Arc<dyn SecretsEngine> = Arc::new(FakeEngine::default());
296        let router = Router::new(vec![EngineMount {
297            prefix: "database/creds/".to_string(),
298            engine: engine.clone(),
299        }]);
300
301        let expired = lease("tok", Utc::now() - chrono::Duration::seconds(10));
302        let active = lease("tok", Utc::now() + chrono::Duration::seconds(3600));
303        lease::store_lease(&storage, &expired).await.unwrap();
304        lease::store_lease(&storage, &active).await.unwrap();
305
306        let reaped = reap_once(&storage, &router).await.unwrap();
307        assert_eq!(reaped, 1);
308        assert_eq!(lease::list_leases(&storage).await.unwrap().len(), 1);
309    }
310
311    #[tokio::test]
312    async fn revoke_leases_for_token_cascades() {
313        let storage = MemStorage::default();
314        let engine: Arc<dyn SecretsEngine> = Arc::new(FakeEngine::default());
315        let router = Router::new(vec![EngineMount {
316            prefix: "database/creds/".to_string(),
317            engine,
318        }]);
319
320        let mine = lease("tok-a", Utc::now() + chrono::Duration::seconds(3600));
321        let theirs = lease("tok-b", Utc::now() + chrono::Duration::seconds(3600));
322        lease::store_lease(&storage, &mine).await.unwrap();
323        lease::store_lease(&storage, &theirs).await.unwrap();
324
325        revoke_leases_for_token(&storage, &router, "tok-a").await.unwrap();
326
327        let remaining = lease::list_leases(&storage).await.unwrap();
328        assert_eq!(remaining.len(), 1);
329        assert_eq!(remaining[0].token_id_hash, "tok-b");
330    }
331}