Skip to main content

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}