Skip to main content

secrets_engine_postgres/
lib.rs

1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use chrono::Utc;
5use rand::RngExt;
6use secrets_core::engine::{EngineError, EngineResult, SecretsEngine};
7use secrets_core::lease::Lease;
8use secrets_core::storage::{StorageBackend, StorageEntry};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use sqlx::PgPool;
12use tokio::sync::RwLock;
13use uuid::Uuid;
14
15const CONFIG_PREFIX: &str = "database/config/";
16const ROLE_PREFIX: &str = "database/roles/";
17
18/// Operator-supplied connection info for a *target* database this engine
19/// manages credentials on — distinct from this server's own storage DB.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DbConfig {
22    pub connection_url: String,
23}
24
25/// A named role: which target DB it applies to, the SQL run to create and
26/// revoke a credential (with `{{name}}`/`{{password}}` placeholders), and
27/// how long a generated credential lives before the reaper revokes it.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct RoleConfig {
30    pub db_name: String,
31    pub creation_statements: Vec<String>,
32    pub revocation_statements: Vec<String>,
33    #[serde(default = "default_ttl_seconds")]
34    pub default_ttl_seconds: i64,
35}
36
37fn default_ttl_seconds() -> i64 {
38    3600
39}
40
41/// Dynamic PostgreSQL credentials engine. Mounted twice in practice: once at
42/// `database/` for `config`/`roles` CRUD, once at `database/creds/` for
43/// on-demand generation — see `secrets-server`'s wiring.
44pub struct PostgresEngine {
45    pools: RwLock<HashMap<String, PgPool>>,
46}
47
48impl Default for PostgresEngine {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl PostgresEngine {
55    pub fn new() -> Self {
56        Self {
57            pools: RwLock::new(HashMap::new()),
58        }
59    }
60
61    async fn pool_for(&self, storage: &dyn StorageBackend, db_name: &str) -> EngineResult<PgPool> {
62        if let Some(pool) = self.pools.read().await.get(db_name) {
63            return Ok(pool.clone());
64        }
65        let config = Self::load_config(storage, db_name)
66            .await?
67            .ok_or_else(|| EngineError::InvalidRequest(format!("unknown database config '{db_name}'")))?;
68        let pool = PgPool::connect(&config.connection_url)
69            .await
70            .map_err(|e| EngineError::Other(e.to_string()))?;
71        self.pools.write().await.insert(db_name.to_string(), pool.clone());
72        Ok(pool)
73    }
74
75    async fn load_config(storage: &dyn StorageBackend, name: &str) -> EngineResult<Option<DbConfig>> {
76        let Some(entry) = storage.get(&format!("{CONFIG_PREFIX}{name}")).await? else {
77            return Ok(None);
78        };
79        Ok(Some(
80            serde_json::from_slice(&entry.value).map_err(|e| EngineError::Other(e.to_string()))?,
81        ))
82    }
83
84    async fn save_config(storage: &dyn StorageBackend, name: &str, config: &DbConfig) -> EngineResult<()> {
85        let value = serde_json::to_vec(config).map_err(|e| EngineError::Other(e.to_string()))?;
86        storage
87            .put(
88                &format!("{CONFIG_PREFIX}{name}"),
89                StorageEntry {
90                    value,
91                    expires_at: None,
92                },
93            )
94            .await?;
95        Ok(())
96    }
97
98    async fn load_role(storage: &dyn StorageBackend, name: &str) -> EngineResult<Option<RoleConfig>> {
99        let Some(entry) = storage.get(&format!("{ROLE_PREFIX}{name}")).await? else {
100            return Ok(None);
101        };
102        Ok(Some(
103            serde_json::from_slice(&entry.value).map_err(|e| EngineError::Other(e.to_string()))?,
104        ))
105    }
106
107    async fn save_role(storage: &dyn StorageBackend, name: &str, role: &RoleConfig) -> EngineResult<()> {
108        let value = serde_json::to_vec(role).map_err(|e| EngineError::Other(e.to_string()))?;
109        storage
110            .put(
111                &format!("{ROLE_PREFIX}{name}"),
112                StorageEntry {
113                    value,
114                    expires_at: None,
115                },
116            )
117            .await?;
118        Ok(())
119    }
120}
121
122/// Generated usernames are restricted to `[a-z0-9_]`, so substituting one
123/// into a `"{{name}}"`-quoted identifier in an operator's SQL template can
124/// never break out of the quotes — the actual defense against
125/// SQL-injection-via-role-name that free-form user input would require.
126fn generate_username(role: &str) -> String {
127    let safe_role: String = role
128        .chars()
129        .map(|c| c.to_ascii_lowercase())
130        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
131        .collect();
132    let suffix = Uuid::new_v4().simple().to_string();
133    format!("v_{safe_role}_{}", &suffix[..12])
134}
135
136/// Hex-encoded, so it can never contain a `'` that would break out of a
137/// `'{{password}}'`-quoted literal in an operator's SQL template.
138fn generate_password() -> String {
139    let bytes: [u8; 24] = rand::rng().random();
140    hex::encode(bytes)
141}
142
143fn render_template(template: &str, username: &str, password: &str) -> String {
144    template.replace("{{name}}", username).replace("{{password}}", password)
145}
146
147#[async_trait]
148impl SecretsEngine for PostgresEngine {
149    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
150        if let Some(name) = path.strip_prefix("roles/") {
151            let role = Self::load_role(storage, name).await?.ok_or(EngineError::NotFound)?;
152            serde_json::to_value(role).map_err(|e| EngineError::Other(e.to_string()))
153        } else {
154            Err(EngineError::InvalidRequest("expected roles/{name}".into()))
155        }
156    }
157
158    async fn write(
159        &self,
160        storage: &dyn StorageBackend,
161        path: &str,
162        data: serde_json::Value,
163    ) -> EngineResult<()> {
164        if let Some(name) = path.strip_prefix("config/") {
165            let config: DbConfig =
166                serde_json::from_value(data).map_err(|e| EngineError::InvalidRequest(e.to_string()))?;
167            Self::save_config(storage, name, &config).await
168        } else if let Some(name) = path.strip_prefix("roles/") {
169            let role: RoleConfig =
170                serde_json::from_value(data).map_err(|e| EngineError::InvalidRequest(e.to_string()))?;
171            Self::save_role(storage, name, &role).await
172        } else {
173            Err(EngineError::InvalidRequest("expected config/{name} or roles/{name}".into()))
174        }
175    }
176
177    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
178        if let Some(name) = path.strip_prefix("config/") {
179            storage.delete(&format!("{CONFIG_PREFIX}{name}")).await?;
180            self.pools.write().await.remove(name);
181            Ok(())
182        } else if let Some(name) = path.strip_prefix("roles/") {
183            storage.delete(&format!("{ROLE_PREFIX}{name}")).await?;
184            Ok(())
185        } else {
186            Err(EngineError::InvalidRequest("expected config/{name} or roles/{name}".into()))
187        }
188    }
189
190    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
191        if let Some(rest) = prefix.strip_prefix("roles/") {
192            let keys = storage.list(&format!("{ROLE_PREFIX}{rest}")).await?;
193            Ok(keys
194                .into_iter()
195                .filter_map(|k| k.strip_prefix(ROLE_PREFIX).map(|s| s.to_string()))
196                .collect())
197        } else if let Some(rest) = prefix.strip_prefix("config/") {
198            let keys = storage.list(&format!("{CONFIG_PREFIX}{rest}")).await?;
199            Ok(keys
200                .into_iter()
201                .filter_map(|k| k.strip_prefix(CONFIG_PREFIX).map(|s| s.to_string()))
202                .collect())
203        } else {
204            Err(EngineError::InvalidRequest("expected config/ or roles/ prefix".into()))
205        }
206    }
207
208    async fn generate(
209        &self,
210        storage: &dyn StorageBackend,
211        role_name: &str,
212    ) -> EngineResult<(serde_json::Value, Lease)> {
213        let role = Self::load_role(storage, role_name).await?.ok_or(EngineError::NotFound)?;
214        let pool = self.pool_for(storage, &role.db_name).await?;
215
216        let username = generate_username(role_name);
217        let password = generate_password();
218
219        for statement in &role.creation_statements {
220            let rendered = render_template(statement, &username, &password);
221            sqlx::raw_sql(sqlx::AssertSqlSafe(rendered))
222                .execute(&pool)
223                .await
224                .map_err(|e| EngineError::Other(e.to_string()))?;
225        }
226
227        let now = Utc::now();
228        let lease = Lease {
229            id: Uuid::new_v4(),
230            // Set by the caller (the HTTP handler knows the requesting
231            // token) before the lease is persisted.
232            token_id_hash: String::new(),
233            engine_mount: "database/creds/".to_string(),
234            internal_data: json!({
235                "role": role_name,
236                "db_name": role.db_name,
237                "username": username,
238            }),
239            issued_at: now,
240            expires_at: now + chrono::Duration::seconds(role.default_ttl_seconds),
241        };
242
243        Ok((json!({ "username": username, "password": password }), lease))
244    }
245
246    async fn revoke(&self, storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
247        let role_name = lease.internal_data["role"]
248            .as_str()
249            .ok_or_else(|| EngineError::Other("lease missing 'role'".into()))?;
250        let db_name = lease.internal_data["db_name"]
251            .as_str()
252            .ok_or_else(|| EngineError::Other("lease missing 'db_name'".into()))?;
253        let username = lease.internal_data["username"]
254            .as_str()
255            .ok_or_else(|| EngineError::Other("lease missing 'username'".into()))?;
256
257        let role = Self::load_role(storage, role_name).await?.ok_or(EngineError::NotFound)?;
258        let pool = self.pool_for(storage, db_name).await?;
259
260        for statement in &role.revocation_statements {
261            let rendered = render_template(statement, username, "");
262            sqlx::raw_sql(sqlx::AssertSqlSafe(rendered))
263                .execute(&pool)
264                .await
265                .map_err(|e| EngineError::Other(e.to_string()))?;
266        }
267        Ok(())
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn generated_username_is_sql_identifier_safe() {
277        let username = generate_username("app'; DROP TABLE users; --");
278        assert!(username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
279    }
280
281    #[test]
282    fn generated_password_has_no_quote_characters() {
283        let password = generate_password();
284        assert!(!password.contains('\''));
285        assert!(!password.contains('"'));
286    }
287
288    #[test]
289    fn template_substitution() {
290        let rendered = render_template(
291            "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}';",
292            "v_app_abc123",
293            "deadbeef",
294        );
295        assert_eq!(
296            rendered,
297            "CREATE ROLE \"v_app_abc123\" WITH LOGIN PASSWORD 'deadbeef';"
298        );
299    }
300}