secrets_core/storage.rs
1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum StorageError {
7 #[error("backend error: {0}")]
8 Backend(String),
9 #[error("not found")]
10 NotFound,
11}
12
13pub type StorageResult<T> = Result<T, StorageError>;
14
15#[derive(Debug, Clone)]
16pub struct StorageEntry {
17 pub value: Vec<u8>,
18 pub expires_at: Option<DateTime<Utc>>,
19}
20
21#[async_trait]
22pub trait StorageBackend: Send + Sync {
23 async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>>;
24 async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()>;
25 async fn delete(&self, path: &str) -> StorageResult<()>;
26 async fn list(&self, prefix: &str) -> StorageResult<Vec<String>>;
27
28 /// Paths under `prefix` whose `expires_at` has already passed.
29 ///
30 /// The default walks every key and fetches it, which is what the lease
31 /// reaper used to do unconditionally. A backend that keeps expiry in a
32 /// queryable column should override this: the reaper runs on an interval
33 /// forever, so the naive scan is O(all leases) per pass per deployment.
34 async fn list_expired(&self, prefix: &str, now: DateTime<Utc>) -> StorageResult<Vec<String>> {
35 let mut expired = Vec::new();
36 for path in self.list(prefix).await? {
37 if let Some(entry) = self.get(&path).await?
38 && entry.expires_at.is_some_and(|at| at <= now)
39 {
40 expired.push(path);
41 }
42 }
43 Ok(expired)
44 }
45
46 /// Replaces `path`'s value only if it still holds exactly `expected`,
47 /// returning false when it changed underneath.
48 ///
49 /// Needed by key rotation, which decrypts and re-encrypts in three steps:
50 /// without this, a write landing between the read and the write would be
51 /// silently overwritten by a re-encryption of stale plaintext.
52 ///
53 /// The default is read-compare-write, which is *not* atomic — override it
54 /// wherever the backend can do the comparison itself.
55 async fn replace_if_unchanged(
56 &self,
57 path: &str,
58 expected: &[u8],
59 entry: StorageEntry,
60 ) -> StorageResult<bool> {
61 match self.get(path).await? {
62 Some(current) if current.value == expected => {
63 self.put(path, entry).await?;
64 Ok(true)
65 }
66 _ => Ok(false),
67 }
68 }
69
70 /// Cheap liveness probe for the health endpoint. It must stay cheap: a
71 /// load balancer calls it constantly, against every replica, forever.
72 async fn ping(&self) -> StorageResult<()> {
73 Ok(())
74 }
75
76 /// Best-effort cross-process mutual exclusion, so exactly one replica
77 /// runs a singleton background task. Granting unconditionally is correct
78 /// for a single-node or in-memory backend, which is why that is the
79 /// default.
80 ///
81 /// A real implementation must tie the lock to the connection holding it,
82 /// so that a crashed node releases it without anyone noticing — that is
83 /// what makes failover work without heartbeats or timeouts. There is
84 /// deliberately no `release`: the lock is held for the process lifetime
85 /// and the backend reclaims it when the connection dies.
86 async fn try_acquire_lock(&self, _key: &str) -> StorageResult<bool> {
87 Ok(true)
88 }
89}