1#![forbid(unsafe_code)]
8
9use async_trait::async_trait;
10use sqlx::postgres::{PgPool, PgPoolOptions};
11use tracing::{debug, info};
12
13use egide_storage::{StorageBackend, StorageError};
14
15#[derive(Clone)]
23pub struct PostgresBackend {
24 pool: PgPool,
25 tenant: String,
26 actor: Option<String>,
27}
28
29impl PostgresBackend {
30 pub async fn connect(database_url: &str, tenant: &str) -> Result<Self, StorageError> {
42 Self::validate_tenant(tenant)?;
43
44 debug!(tenant = %tenant, "Opening Postgres connection");
45
46 let pool = PgPoolOptions::new()
47 .max_connections(5)
48 .connect(database_url)
49 .await
50 .map_err(|e| StorageError::ConnectionFailed(e.to_string()))?;
51
52 let backend = Self {
53 pool,
54 tenant: tenant.to_string(),
55 actor: None,
56 };
57
58 backend.migrate().await?;
59
60 info!(tenant = %tenant, "Postgres backend ready");
61
62 Ok(backend)
63 }
64
65 #[must_use]
70 pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
71 self.actor = Some(actor.into());
72 self
73 }
74
75 #[must_use]
77 pub fn current_actor(&self) -> Option<String> {
78 self.actor.clone()
79 }
80
81 fn validate_tenant(tenant: &str) -> Result<(), StorageError> {
86 if tenant.is_empty() {
87 return Err(StorageError::InvalidInput("tenant cannot be empty".into()));
88 }
89
90 if tenant.len() > 64 {
91 return Err(StorageError::InvalidInput("tenant name too long".into()));
92 }
93
94 let valid = tenant
95 .chars()
96 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
97
98 if !valid {
99 return Err(StorageError::InvalidInput(
100 "tenant must match [a-z0-9_-]+".into(),
101 ));
102 }
103
104 Ok(())
105 }
106
107 async fn migrate(&self) -> Result<(), StorageError> {
111 sqlx::query(sqlx::AssertSqlSafe(format!(
112 "CREATE SCHEMA IF NOT EXISTS \"{}\"",
113 self.tenant
114 )))
115 .execute(&self.pool)
116 .await
117 .map_err(|e| StorageError::ConnectionFailed(format!("migration failed: {e}")))?;
118
119 sqlx::query(sqlx::AssertSqlSafe(format!(
120 r#"
121 CREATE TABLE IF NOT EXISTS "{}".kv_store (
122 key TEXT PRIMARY KEY,
123 value BYTEA NOT NULL,
124 version BIGINT NOT NULL DEFAULT 1,
125 created_at BIGINT NOT NULL,
126 updated_at BIGINT NOT NULL
127 )
128 "#,
129 self.tenant
130 )))
131 .execute(&self.pool)
132 .await
133 .map_err(|e| StorageError::ConnectionFailed(format!("migration failed: {e}")))?;
134
135 sqlx::query(sqlx::AssertSqlSafe(format!(
136 r#"
137 CREATE TABLE IF NOT EXISTS "{}".kv_history (
138 id BIGSERIAL PRIMARY KEY,
139 key TEXT NOT NULL,
140 value BYTEA,
141 version BIGINT NOT NULL,
142 operation TEXT NOT NULL,
143 actor TEXT,
144 timestamp BIGINT NOT NULL
145 )
146 "#,
147 self.tenant
148 )))
149 .execute(&self.pool)
150 .await
151 .map_err(|e| StorageError::ConnectionFailed(format!("migration failed: {e}")))?;
152
153 sqlx::query(sqlx::AssertSqlSafe(format!(
154 "CREATE INDEX IF NOT EXISTS idx_history_key ON \"{}\".kv_history (key)",
155 self.tenant
156 )))
157 .execute(&self.pool)
158 .await
159 .map_err(|e| StorageError::ConnectionFailed(format!("migration failed: {e}")))?;
160
161 sqlx::query(sqlx::AssertSqlSafe(format!(
162 "CREATE INDEX IF NOT EXISTS idx_history_timestamp ON \"{}\".kv_history (timestamp)",
163 self.tenant
164 )))
165 .execute(&self.pool)
166 .await
167 .map_err(|e| StorageError::ConnectionFailed(format!("migration failed: {e}")))?;
168
169 debug!("Migrations complete");
170
171 Ok(())
172 }
173
174 fn now() -> i64 {
176 std::time::SystemTime::now()
177 .duration_since(std::time::UNIX_EPOCH)
178 .expect("system time before UNIX epoch")
179 .as_secs()
180 .cast_signed()
181 }
182}
183
184#[async_trait]
185impl StorageBackend for PostgresBackend {
186 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, StorageError> {
187 let sql = format!(
188 "SELECT value FROM \"{}\".kv_store WHERE key = $1",
189 self.tenant
190 );
191
192 let row: Option<(Vec<u8>,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql))
193 .bind(key)
194 .fetch_optional(&self.pool)
195 .await
196 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
197
198 Ok(row.map(|(v,)| v))
199 }
200
201 async fn put(&self, key: &str, value: &[u8]) -> Result<(), StorageError> {
202 let now = Self::now();
203
204 let select_sql = format!(
205 "SELECT version FROM \"{}\".kv_store WHERE key = $1",
206 self.tenant
207 );
208 let existing: Option<(i64,)> = sqlx::query_as(sqlx::AssertSqlSafe(select_sql))
209 .bind(key)
210 .fetch_optional(&self.pool)
211 .await
212 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
213
214 let (version, operation) = match existing {
215 Some((v,)) => (v + 1, "update"),
216 None => (1, "create"),
217 };
218
219 let upsert_sql = format!(
220 r#"
221 INSERT INTO "{}".kv_store (key, value, version, created_at, updated_at)
222 VALUES ($1, $2, $3, $4, $5)
223 ON CONFLICT (key) DO UPDATE SET
224 value = EXCLUDED.value,
225 version = EXCLUDED.version,
226 updated_at = EXCLUDED.updated_at
227 "#,
228 self.tenant
229 );
230 sqlx::query(sqlx::AssertSqlSafe(upsert_sql))
231 .bind(key)
232 .bind(value)
233 .bind(version)
234 .bind(now)
235 .bind(now)
236 .execute(&self.pool)
237 .await
238 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
239
240 let history_sql = format!(
241 "INSERT INTO \"{}\".kv_history (key, value, version, operation, actor, timestamp) VALUES ($1, $2, $3, $4, $5, $6)",
242 self.tenant
243 );
244 sqlx::query(sqlx::AssertSqlSafe(history_sql))
245 .bind(key)
246 .bind(value)
247 .bind(version)
248 .bind(operation)
249 .bind(self.actor.as_deref())
250 .bind(now)
251 .execute(&self.pool)
252 .await
253 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
254
255 Ok(())
256 }
257
258 async fn delete(&self, key: &str) -> Result<(), StorageError> {
259 let select_sql = format!(
260 "SELECT version FROM \"{}\".kv_store WHERE key = $1",
261 self.tenant
262 );
263 let existing: Option<(i64,)> = sqlx::query_as(sqlx::AssertSqlSafe(select_sql))
264 .bind(key)
265 .fetch_optional(&self.pool)
266 .await
267 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
268
269 if let Some((version,)) = existing {
270 let now = Self::now();
271
272 let delete_sql = format!("DELETE FROM \"{}\".kv_store WHERE key = $1", self.tenant);
273 sqlx::query(sqlx::AssertSqlSafe(delete_sql))
274 .bind(key)
275 .execute(&self.pool)
276 .await
277 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
278
279 let history_sql = format!(
280 "INSERT INTO \"{}\".kv_history (key, value, version, operation, actor, timestamp) VALUES ($1, NULL, $2, 'delete', $3, $4)",
281 self.tenant
282 );
283 sqlx::query(sqlx::AssertSqlSafe(history_sql))
284 .bind(key)
285 .bind(version + 1)
286 .bind(self.actor.as_deref())
287 .bind(now)
288 .execute(&self.pool)
289 .await
290 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
291 }
292
293 Ok(())
294 }
295
296 async fn list(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
297 let pattern = format!("{prefix}%");
298 let sql = format!(
299 "SELECT key FROM \"{}\".kv_store WHERE key LIKE $1",
300 self.tenant
301 );
302
303 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql))
304 .bind(&pattern)
305 .fetch_all(&self.pool)
306 .await
307 .map_err(|e| StorageError::QueryFailed(e.to_string()))?;
308
309 Ok(rows.into_iter().map(|(k,)| k).collect())
310 }
311}
312
313#[cfg(test)]
314#[allow(clippy::disallowed_methods)]
315mod tests {
316 use super::*;
317 use testcontainers_modules::postgres::Postgres;
318 use testcontainers_modules::testcontainers::runners::AsyncRunner;
319 use testcontainers_modules::testcontainers::ContainerAsync;
320
321 const ANY_URL: &str = "postgres://unused:unused@127.0.0.1:1/unused";
324
325 async fn start_postgres() -> (ContainerAsync<Postgres>, String) {
328 let node = Postgres::default().start().await.unwrap();
329 let port = node.get_host_port_ipv4(5432).await.unwrap();
330 let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
331 (node, url)
332 }
333
334 async fn setup() -> (ContainerAsync<Postgres>, PostgresBackend) {
336 let (node, url) = start_postgres().await;
337 let backend = PostgresBackend::connect(&url, "test-tenant").await.unwrap();
338 (node, backend)
339 }
340
341 #[tokio::test]
342 async fn test_tenant_validation_empty() {
343 let result = PostgresBackend::connect(ANY_URL, "").await;
344 assert!(matches!(result, Err(StorageError::InvalidInput(_))));
345 }
346
347 #[tokio::test]
348 async fn test_tenant_validation_too_long() {
349 let long = "a".repeat(65);
350 let result = PostgresBackend::connect(ANY_URL, &long).await;
351 assert!(matches!(result, Err(StorageError::InvalidInput(_))));
352 }
353
354 #[tokio::test]
355 async fn test_tenant_validation_invalid_chars() {
356 let invalid_names = [
357 "Tenant",
358 "my tenant",
359 "tenant/sub",
360 "../escape",
361 "tenant.db",
362 ];
363
364 for name in invalid_names {
365 let result = PostgresBackend::connect(ANY_URL, name).await;
366 assert!(
367 matches!(result, Err(StorageError::InvalidInput(_))),
368 "expected InvalidInput for tenant name: {name}"
369 );
370 }
371 }
372
373 #[tokio::test]
374 async fn test_crud_roundtrip() {
375 let (_node, backend) = setup().await;
376
377 let result = backend.get("secret/key").await.unwrap();
378 assert!(result.is_none());
379
380 backend.put("secret/key", b"secret-value").await.unwrap();
381
382 let result = backend.get("secret/key").await.unwrap();
383 assert_eq!(result, Some(b"secret-value".to_vec()));
384
385 backend.put("secret/key", b"new-value").await.unwrap();
386 let result = backend.get("secret/key").await.unwrap();
387 assert_eq!(result, Some(b"new-value".to_vec()));
388 }
389
390 #[tokio::test]
391 async fn test_version_increments() {
392 let (_node, backend) = setup().await;
393
394 backend.put("key", b"v1").await.unwrap();
395 backend.put("key", b"v2").await.unwrap();
396 backend.put("key", b"v3").await.unwrap();
397
398 let sql = "SELECT version FROM \"test-tenant\".kv_store WHERE key = $1";
399 let row: (i64,) = sqlx::query_as(sql)
400 .bind("key")
401 .fetch_one(&backend.pool)
402 .await
403 .unwrap();
404
405 assert_eq!(row.0, 3);
406 }
407
408 #[tokio::test]
409 async fn test_binary_data() {
410 let (_node, backend) = setup().await;
411
412 let binary_data: Vec<u8> = (0..=255).collect();
413 backend.put("binary", &binary_data).await.unwrap();
414
415 let result = backend.get("binary").await.unwrap();
416 assert_eq!(result, Some(binary_data));
417 }
418
419 #[tokio::test]
420 async fn test_with_actor() {
421 let (_node, backend) = setup().await;
422 let backend = backend.with_actor("user:alice");
423
424 backend.put("key", b"value").await.unwrap();
425
426 let sql = "SELECT actor FROM \"test-tenant\".kv_history WHERE key = $1";
427 let row: (String,) = sqlx::query_as(sql)
428 .bind("key")
429 .fetch_one(&backend.pool)
430 .await
431 .unwrap();
432
433 assert_eq!(row.0, "user:alice");
434 }
435
436 #[tokio::test]
437 async fn test_delete_roundtrip() {
438 let (_node, backend) = setup().await;
439
440 backend.put("secret/key", b"secret-value").await.unwrap();
441 backend.delete("secret/key").await.unwrap();
442 let result = backend.get("secret/key").await.unwrap();
443 assert!(result.is_none());
444 }
445
446 #[tokio::test]
447 async fn test_delete_nonexistent_is_ok() {
448 let (_node, backend) = setup().await;
449
450 backend.delete("nonexistent").await.unwrap();
451 }
452
453 #[tokio::test]
454 async fn test_history_records_operations() {
455 let (_node, backend) = setup().await;
456 let backend = backend.with_actor("system");
457
458 backend.put("key", b"v1").await.unwrap();
459 backend.put("key", b"v2").await.unwrap();
460 backend.delete("key").await.unwrap();
461
462 let sql =
463 "SELECT operation, version FROM \"test-tenant\".kv_history WHERE key = $1 ORDER BY id";
464 let rows: Vec<(String, i64)> = sqlx::query_as(sql)
465 .bind("key")
466 .fetch_all(&backend.pool)
467 .await
468 .unwrap();
469
470 assert_eq!(rows.len(), 3);
471 assert_eq!(rows[0], ("create".to_string(), 1));
472 assert_eq!(rows[1], ("update".to_string(), 2));
473 assert_eq!(rows[2], ("delete".to_string(), 3));
474 }
475
476 #[tokio::test]
477 async fn test_list_prefix() {
478 let (_node, backend) = setup().await;
479
480 backend.put("prod/app/db", b"1").await.unwrap();
481 backend.put("prod/app/api", b"2").await.unwrap();
482 backend.put("prod/other/key", b"3").await.unwrap();
483 backend.put("dev/app/db", b"4").await.unwrap();
484
485 let mut keys = backend.list("prod/").await.unwrap();
486 keys.sort();
487 assert_eq!(keys, vec!["prod/app/api", "prod/app/db", "prod/other/key"]);
488
489 let mut keys = backend.list("prod/app/").await.unwrap();
490 keys.sort();
491 assert_eq!(keys, vec!["prod/app/api", "prod/app/db"]);
492
493 let keys = backend.list("staging/").await.unwrap();
494 assert!(keys.is_empty());
495 }
496
497 #[tokio::test]
498 async fn test_list_all() {
499 let (_node, backend) = setup().await;
500
501 backend.put("a", b"1").await.unwrap();
502 backend.put("b", b"2").await.unwrap();
503
504 let mut keys = backend.list("").await.unwrap();
505 keys.sort();
506 assert_eq!(keys, vec!["a", "b"]);
507 }
508
509 #[tokio::test]
510 async fn test_tenant_isolation() {
511 let (_node, url) = start_postgres().await;
513
514 let backend_a = PostgresBackend::connect(&url, "tenant-a").await.unwrap();
515 let backend_b = PostgresBackend::connect(&url, "tenant-b").await.unwrap();
516
517 backend_a.put("shared-key", b"value-a").await.unwrap();
518 backend_b.put("shared-key", b"value-b").await.unwrap();
519
520 assert_eq!(
521 backend_a.get("shared-key").await.unwrap(),
522 Some(b"value-a".to_vec())
523 );
524 assert_eq!(
525 backend_b.get("shared-key").await.unwrap(),
526 Some(b"value-b".to_vec())
527 );
528 }
529}