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_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
68pub 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}