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
20pub 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
36pub 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
51pub 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
66pub const REAPER_LOCK: &str = "secrets/lease-reaper";
70
71pub 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
88pub 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 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 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 #[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 #[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}