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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DbConfig {
22 pub connection_url: String,
23}
24
25#[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
41pub 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
122fn 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
136fn 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 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}