Skip to main content

egide_storage_postgres/
lib.rs

1//! # Egide Storage - `PostgreSQL` Backend
2//!
3//! `PostgreSQL` implementation of the storage backend with schema-per-tenant
4//! isolation. Each tenant gets its own Postgres schema; every query qualifies
5//! its tables explicitly, so tenant isolation never depends on session state.
6
7#![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/// `PostgreSQL` storage backend with schema-per-tenant isolation.
16///
17/// Each tenant maps to a dedicated Postgres schema. All queries qualify their
18/// tables as `"{tenant}".kv_store` / `"{tenant}".kv_history`. The tenant name is
19/// interpolated into SQL (a schema identifier cannot be a bind parameter), so
20/// the `[a-z0-9_-]+` validation in [`PostgresBackend::connect`] is the single
21/// anti-injection barrier.
22#[derive(Clone)]
23pub struct PostgresBackend {
24    pool: PgPool,
25    tenant: String,
26    actor: Option<String>,
27}
28
29impl PostgresBackend {
30    /// Connects to Postgres and prepares the tenant schema.
31    ///
32    /// # Arguments
33    ///
34    /// * `database_url` - Postgres connection URL
35    /// * `tenant` - Tenant identifier (must match `[a-z0-9_-]+`)
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if the tenant name is invalid, the connection fails,
40    /// or the schema migration fails.
41    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    /// Sets the actor for audit logging.
66    ///
67    /// Returns a new instance with the actor set. All operations performed with
68    /// this instance will be logged with this actor.
69    #[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    /// Returns the current actor, if set.
76    #[must_use]
77    pub fn current_actor(&self) -> Option<String> {
78        self.actor.clone()
79    }
80
81    /// Validates that a tenant name is safe.
82    ///
83    /// Only allows: lowercase letters, digits, underscore, hyphen. This is also
84    /// the anti-injection guard for the interpolated schema name.
85    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    /// Runs schema migrations for the tenant.
108    ///
109    /// Creates the tenant schema and all required tables if they do not exist.
110    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    /// Returns the current Unix timestamp in seconds.
175    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    // A bogus URL is fine: validation rejects invalid tenants before the pool
322    // is ever opened, so these tests need no Docker.
323    const ANY_URL: &str = "postgres://unused:unused@127.0.0.1:1/unused";
324
325    /// Starts an ephemeral Postgres container and returns it with its URL.
326    /// The returned container must be kept alive for the test duration.
327    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    /// Builds a backend on a fresh container with the `test-tenant` schema.
335    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        // Two tenants (two schemas) on the SAME Postgres instance.
512        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}