secrets_storage_postgres/
lib.rs1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use secrets_core::storage::{StorageBackend, StorageEntry, StorageError, StorageResult};
5use sha2::{Digest, Sha256};
6use sqlx::pool::PoolConnection;
7use sqlx::{PgPool, Postgres};
8use tokio::sync::Mutex;
9
10pub struct PgStorage {
11 pool: PgPool,
12 locks: Mutex<HashMap<String, PoolConnection<Postgres>>>,
21}
22
23impl PgStorage {
24 pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
25 let pool = PgPool::connect(database_url).await?;
26 sqlx::migrate!("./src/migrations").run(&pool).await?;
27 Ok(Self {
28 pool,
29 locks: Mutex::new(HashMap::new()),
30 })
31 }
32}
33
34fn advisory_lock_id(key: &str) -> i64 {
38 let digest = Sha256::digest(key.as_bytes());
39 i64::from_be_bytes(digest[..8].try_into().expect("sha256 digest is 32 bytes"))
40}
41
42#[async_trait]
43impl StorageBackend for PgStorage {
44 async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
45 let row = sqlx::query_as::<_, (Vec<u8>, Option<chrono::DateTime<chrono::Utc>>)>(
46 "SELECT value, expires_at FROM kv_store WHERE path = $1",
47 )
48 .bind(path)
49 .fetch_optional(&self.pool)
50 .await
51 .map_err(|e| StorageError::Backend(e.to_string()))?;
52
53 Ok(row.map(|(value, expires_at)| StorageEntry { value, expires_at }))
54 }
55
56 async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
57 sqlx::query(
58 "INSERT INTO kv_store (path, value, expires_at, updated_at)
59 VALUES ($1, $2, $3, now())
60 ON CONFLICT (path) DO UPDATE SET value = $2, expires_at = $3, updated_at = now()",
61 )
62 .bind(path)
63 .bind(entry.value)
64 .bind(entry.expires_at)
65 .execute(&self.pool)
66 .await
67 .map_err(|e| StorageError::Backend(e.to_string()))?;
68 Ok(())
69 }
70
71 async fn delete(&self, path: &str) -> StorageResult<()> {
72 sqlx::query("DELETE FROM kv_store WHERE path = $1")
73 .bind(path)
74 .execute(&self.pool)
75 .await
76 .map_err(|e| StorageError::Backend(e.to_string()))?;
77 Ok(())
78 }
79
80 async fn ping(&self) -> StorageResult<()> {
81 sqlx::query("SELECT 1")
82 .execute(&self.pool)
83 .await
84 .map_err(|e| StorageError::Backend(e.to_string()))?;
85 Ok(())
86 }
87
88 async fn list_expired(
92 &self,
93 prefix: &str,
94 now: chrono::DateTime<chrono::Utc>,
95 ) -> StorageResult<Vec<String>> {
96 let rows: Vec<(String,)> = sqlx::query_as(
97 "SELECT path FROM kv_store
98 WHERE path LIKE $1 || '%' AND expires_at IS NOT NULL AND expires_at <= $2",
99 )
100 .bind(prefix)
101 .bind(now)
102 .fetch_all(&self.pool)
103 .await
104 .map_err(|e| StorageError::Backend(e.to_string()))?;
105 Ok(rows.into_iter().map(|(p,)| p).collect())
106 }
107
108 async fn replace_if_unchanged(
110 &self,
111 path: &str,
112 expected: &[u8],
113 entry: StorageEntry,
114 ) -> StorageResult<bool> {
115 let result = sqlx::query(
116 "UPDATE kv_store SET value = $3, expires_at = $4, updated_at = now()
117 WHERE path = $1 AND value = $2",
118 )
119 .bind(path)
120 .bind(expected)
121 .bind(entry.value)
122 .bind(entry.expires_at)
123 .execute(&self.pool)
124 .await
125 .map_err(|e| StorageError::Backend(e.to_string()))?;
126 Ok(result.rows_affected() == 1)
127 }
128
129 async fn try_acquire_lock(&self, key: &str) -> StorageResult<bool> {
130 let mut locks = self.locks.lock().await;
131 if locks.contains_key(key) {
135 return Ok(true);
136 }
137
138 let mut conn = self
139 .pool
140 .acquire()
141 .await
142 .map_err(|e| StorageError::Backend(e.to_string()))?;
143 let (acquired,): (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
144 .bind(advisory_lock_id(key))
145 .fetch_one(&mut *conn)
146 .await
147 .map_err(|e| StorageError::Backend(e.to_string()))?;
148
149 if acquired {
150 locks.insert(key.to_string(), conn);
151 }
152 Ok(acquired)
153 }
154
155 async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
156 let rows: Vec<(String,)> =
157 sqlx::query_as("SELECT path FROM kv_store WHERE path LIKE $1 || '%'")
158 .bind(prefix)
159 .fetch_all(&self.pool)
160 .await
161 .map_err(|e| StorageError::Backend(e.to_string()))?;
162 Ok(rows.into_iter().map(|(p,)| p).collect())
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::advisory_lock_id;
169
170 #[test]
173 fn advisory_lock_ids_are_stable_and_distinct() {
174 assert_eq!(advisory_lock_id("secrets/lease-reaper"), advisory_lock_id("secrets/lease-reaper"));
175 assert_ne!(advisory_lock_id("secrets/lease-reaper"), advisory_lock_id("secrets/other"));
176 }
177}