1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use chrono::Utc;
5use rand::RngExt;
6use secrets_core::engine::{
7 CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
8 SecretsEngine, TtlDoc,
9};
10use secrets_core::lease::Lease;
11use secrets_core::storage::{StorageBackend, StorageEntry};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use sqlx::PgPool;
15use tokio::sync::RwLock;
16use uuid::Uuid;
17
18const CONFIG_PREFIX: &str = "database/config/";
19const ROLE_PREFIX: &str = "database/roles/";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DbConfig {
25 pub connection_url: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct RoleConfig {
33 pub db_name: String,
34 pub creation_statements: Vec<String>,
35 pub revocation_statements: Vec<String>,
36 #[serde(default = "default_ttl_seconds")]
37 pub default_ttl_seconds: i64,
38}
39
40fn default_ttl_seconds() -> i64 {
41 3600
42}
43
44pub struct PostgresEngine {
48 pools: RwLock<HashMap<String, PgPool>>,
49}
50
51impl Default for PostgresEngine {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl PostgresEngine {
58 pub fn new() -> Self {
59 Self {
60 pools: RwLock::new(HashMap::new()),
61 }
62 }
63
64 async fn pool_for(&self, storage: &dyn StorageBackend, db_name: &str) -> EngineResult<PgPool> {
65 if let Some(pool) = self.pools.read().await.get(db_name) {
66 return Ok(pool.clone());
67 }
68 let config = Self::load_config(storage, db_name)
69 .await?
70 .ok_or_else(|| EngineError::InvalidRequest(format!("unknown database config '{db_name}'")))?;
71 let pool = PgPool::connect(&config.connection_url)
72 .await
73 .map_err(|e| EngineError::Other(e.to_string()))?;
74 self.pools.write().await.insert(db_name.to_string(), pool.clone());
75 Ok(pool)
76 }
77
78 async fn load_config(storage: &dyn StorageBackend, name: &str) -> EngineResult<Option<DbConfig>> {
79 let Some(entry) = storage.get(&format!("{CONFIG_PREFIX}{name}")).await? else {
80 return Ok(None);
81 };
82 Ok(Some(
83 serde_json::from_slice(&entry.value).map_err(|e| EngineError::Other(e.to_string()))?,
84 ))
85 }
86
87 async fn save_config(storage: &dyn StorageBackend, name: &str, config: &DbConfig) -> EngineResult<()> {
88 let value = serde_json::to_vec(config).map_err(|e| EngineError::Other(e.to_string()))?;
89 storage
90 .put(
91 &format!("{CONFIG_PREFIX}{name}"),
92 StorageEntry {
93 value,
94 expires_at: None,
95 },
96 )
97 .await?;
98 Ok(())
99 }
100
101 async fn load_role(storage: &dyn StorageBackend, name: &str) -> EngineResult<Option<RoleConfig>> {
102 let Some(entry) = storage.get(&format!("{ROLE_PREFIX}{name}")).await? else {
103 return Ok(None);
104 };
105 Ok(Some(
106 serde_json::from_slice(&entry.value).map_err(|e| EngineError::Other(e.to_string()))?,
107 ))
108 }
109
110 async fn save_role(storage: &dyn StorageBackend, name: &str, role: &RoleConfig) -> EngineResult<()> {
111 let value = serde_json::to_vec(role).map_err(|e| EngineError::Other(e.to_string()))?;
112 storage
113 .put(
114 &format!("{ROLE_PREFIX}{name}"),
115 StorageEntry {
116 value,
117 expires_at: None,
118 },
119 )
120 .await?;
121 Ok(())
122 }
123}
124
125fn generate_username(role: &str) -> String {
130 let safe_role: String = role
131 .chars()
132 .map(|c| c.to_ascii_lowercase())
133 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
134 .collect();
135 let suffix = Uuid::new_v4().simple().to_string();
136 format!("v_{safe_role}_{}", &suffix[..12])
137}
138
139fn generate_password() -> String {
142 let bytes: [u8; 24] = rand::rng().random();
143 hex::encode(bytes)
144}
145
146fn render_template(template: &str, username: &str, password: &str) -> String {
147 template.replace("{{name}}", username).replace("{{password}}", password)
148}
149
150#[async_trait]
151impl SecretsEngine for PostgresEngine {
152 fn doc(&self) -> EngineDoc {
153 EngineDoc {
154 provider: "PostgreSQL".to_string(),
155 mechanism: "per-lease database role created with the role's CREATE \
156 templates and dropped with its revocation templates"
157 .to_string(),
158 shape: CredentialShape::MintAndRevoke,
159 revocable: true,
160 revoke_effect: "runs the role's revocation_statements — normally \
161 DROP ROLE — so the credential stops working \
162 immediately and existing sessions are terminated."
163 .to_string(),
164 ttl: TtlDoc {
165 min_seconds: Some(1),
166 max_seconds: None,
167 fixed: false,
168 note: "set per role by default_ttl_seconds. PostgreSQL imposes no \
169 limit of its own — the reaper is the only clock, so a \
170 missed revocation leaves the role in place."
171 .to_string(),
172 },
173 scoping: "whatever the role's creation_statements GRANT. Scope is \
174 entirely in the operator's SQL, so grant narrowly."
175 .to_string(),
176 root_credential: "a privileged connection string per target database, \
177 held at database/config/{name}. It can create and drop \
178 roles, so treat it as the blast radius of this mount."
179 .to_string(),
180 paths: vec![
181 PathDoc::new(
182 "database/config/{name}",
183 &["POST"],
184 "sudo",
185 "register a target database's privileged connection URL",
186 ),
187 PathDoc::new(
188 "database/roles/{role}",
189 &["POST"],
190 "create",
191 "define the CREATE/DROP SQL templates and TTL for a role",
192 ),
193 PathDoc::new(
194 "database/creds/{role}",
195 &["GET"],
196 "read",
197 "mint a credential for that role and open a lease",
198 ),
199 ],
200 docs_url: Some("README.md#example-dynamic-postgresql-credentials".to_string()),
201 caveats: vec![
202 "Generated usernames are restricted to [a-z0-9_] and passwords to hex, \
203 so template substitution cannot break out of the quotes in your SQL."
204 .to_string(),
205 "The reaper takes no distributed lock, so run one node."
206 .to_string(),
207 ],
208 }
209 }
210
211 async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
212 if let Some(name) = path.strip_prefix("roles/") {
213 let role = Self::load_role(storage, name).await?.ok_or(EngineError::NotFound)?;
214 serde_json::to_value(role).map_err(|e| EngineError::Other(e.to_string()))
215 } else {
216 Err(EngineError::InvalidRequest("expected roles/{name}".into()))
217 }
218 }
219
220 async fn write(
221 &self,
222 storage: &dyn StorageBackend,
223 path: &str,
224 data: serde_json::Value,
225 ) -> EngineResult<()> {
226 if let Some(name) = path.strip_prefix("config/") {
227 let config: DbConfig =
228 serde_json::from_value(data).map_err(|e| EngineError::InvalidRequest(e.to_string()))?;
229 Self::save_config(storage, name, &config).await
230 } else if let Some(name) = path.strip_prefix("roles/") {
231 let role: RoleConfig =
232 serde_json::from_value(data).map_err(|e| EngineError::InvalidRequest(e.to_string()))?;
233 Self::save_role(storage, name, &role).await
234 } else {
235 Err(EngineError::InvalidRequest("expected config/{name} or roles/{name}".into()))
236 }
237 }
238
239 async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
240 if let Some(name) = path.strip_prefix("config/") {
241 storage.delete(&format!("{CONFIG_PREFIX}{name}")).await?;
242 self.pools.write().await.remove(name);
243 Ok(())
244 } else if let Some(name) = path.strip_prefix("roles/") {
245 storage.delete(&format!("{ROLE_PREFIX}{name}")).await?;
246 Ok(())
247 } else {
248 Err(EngineError::InvalidRequest("expected config/{name} or roles/{name}".into()))
249 }
250 }
251
252 async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
253 if let Some(rest) = prefix.strip_prefix("roles/") {
254 let keys = storage.list(&format!("{ROLE_PREFIX}{rest}")).await?;
255 Ok(keys
256 .into_iter()
257 .filter_map(|k| k.strip_prefix(ROLE_PREFIX).map(|s| s.to_string()))
258 .collect())
259 } else if let Some(rest) = prefix.strip_prefix("config/") {
260 let keys = storage.list(&format!("{CONFIG_PREFIX}{rest}")).await?;
261 Ok(keys
262 .into_iter()
263 .filter_map(|k| k.strip_prefix(CONFIG_PREFIX).map(|s| s.to_string()))
264 .collect())
265 } else {
266 Err(EngineError::InvalidRequest("expected config/ or roles/ prefix".into()))
267 }
268 }
269
270 async fn generate(
271 &self,
272 storage: &dyn StorageBackend,
273 role_name: &str,
274 ) -> EngineResult<GeneratedCredential> {
275 let role = Self::load_role(storage, role_name).await?.ok_or(EngineError::NotFound)?;
276 let pool = self.pool_for(storage, &role.db_name).await?;
277
278 let username = generate_username(role_name);
279 let password = generate_password();
280
281 for statement in &role.creation_statements {
282 let rendered = render_template(statement, &username, &password);
283 sqlx::raw_sql(sqlx::AssertSqlSafe(rendered))
284 .execute(&pool)
285 .await
286 .map_err(|e| EngineError::Other(e.to_string()))?;
287 }
288
289 let now = Utc::now();
290 let lease = Lease {
291 id: Uuid::new_v4(),
292 token_id_hash: String::new(),
295 engine_mount: "database/creds/".to_string(),
296 internal_data: json!({
297 "role": role_name,
298 "db_name": role.db_name,
299 "username": username,
300 }),
301 issued_at: now,
302 expires_at: now + chrono::Duration::seconds(role.default_ttl_seconds),
303 };
304
305 Ok(GeneratedCredential::new(
306 json!({ "username": username, "password": password }),
307 lease,
308 vec![
309 format!("database:{}", role.db_name),
310 format!("role:{role_name}"),
311 format!("user:{username}"),
312 ],
313 ))
314 }
315
316 async fn revoke(&self, storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
317 let role_name = lease.internal_data["role"]
318 .as_str()
319 .ok_or_else(|| EngineError::Other("lease missing 'role'".into()))?;
320 let db_name = lease.internal_data["db_name"]
321 .as_str()
322 .ok_or_else(|| EngineError::Other("lease missing 'db_name'".into()))?;
323 let username = lease.internal_data["username"]
324 .as_str()
325 .ok_or_else(|| EngineError::Other("lease missing 'username'".into()))?;
326
327 let role = Self::load_role(storage, role_name).await?.ok_or(EngineError::NotFound)?;
328 let pool = self.pool_for(storage, db_name).await?;
329
330 for statement in &role.revocation_statements {
331 let rendered = render_template(statement, username, "");
332 sqlx::raw_sql(sqlx::AssertSqlSafe(rendered))
333 .execute(&pool)
334 .await
335 .map_err(|e| EngineError::Other(e.to_string()))?;
336 }
337 Ok(())
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn generated_username_is_sql_identifier_safe() {
347 let username = generate_username("app'; DROP TABLE users; --");
348 assert!(username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
349 }
350
351 #[test]
352 fn generated_password_has_no_quote_characters() {
353 let password = generate_password();
354 assert!(!password.contains('\''));
355 assert!(!password.contains('"'));
356 }
357
358 #[test]
359 fn template_substitution() {
360 let rendered = render_template(
361 "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}';",
362 "v_app_abc123",
363 "deadbeef",
364 );
365 assert_eq!(
366 rendered,
367 "CREATE ROLE \"v_app_abc123\" WITH LOGIN PASSWORD 'deadbeef';"
368 );
369 }
370}