From c2478c2b1763e404a21d7821b28d2f6b94477078 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Wed, 17 Jun 2026 14:51:48 +0200 Subject: [PATCH 1/9] wip --- .gitignore | 2 + .schema/users.schema.json | 7 ++ integration/vault/setup.sh | 21 +++- integration/vault/users.toml | 9 ++ pgdog-config/src/users.rs | 25 ++++- pgdog/src/backend/auth/vault.rs | 129 +++++++++++++++++++++-- pgdog/src/backend/pool/connection/mod.rs | 3 + pgdog/src/frontend/client/mod.rs | 23 ++++ 8 files changed, 209 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 5db985b82..1893e1511 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ integration/**/*.test enterprise/control-web/dist enterprise/control-web/node_modules enterprise/control-web/.vite/* + +.tokensave diff --git a/.schema/users.schema.json b/.schema/users.schema.json index 48c4b4b57..c0e468973 100644 --- a/.schema/users.schema.json +++ b/.schema/users.schema.json @@ -102,6 +102,13 @@ "type": "boolean", "default": false }, + "client_vault_path": { + "description": "Vault path to a static database role used to verify client passwords,\ne.g. `database/static-creds/my-role`. When set, PgDog fetches the\ncurrent password from Vault and compares it to what the client\nprovides instead of using a statically configured password.", + "type": [ + "string", + "null" + ] + }, "cross_shard_disabled": { "description": "Disable cross-shard queries for this user.", "type": [ diff --git a/integration/vault/setup.sh b/integration/vault/setup.sh index 71b6f5228..4c2022341 100755 --- a/integration/vault/setup.sh +++ b/integration/vault/setup.sh @@ -21,7 +21,7 @@ $VAULT secrets enable database 2>/dev/null || true $VAULT write database/config/pgdog \ plugin_name=postgresql-database-plugin \ - allowed_roles="pgdog-role" \ + allowed_roles="pgdog-role,pgdog-static-role" \ connection_url="postgresql://{{username}}:{{password}}@postgres:5432/pgdog?sslmode=disable" \ username="postgres" \ password="postgres" @@ -33,6 +33,19 @@ $VAULT write database/roles/pgdog-role \ default_ttl="10m" \ max_ttl="30m" +echo "Creating static Postgres user for Vault static role..." +docker compose exec -T postgres psql -U postgres -c \ + "CREATE USER pgdog_static WITH LOGIN PASSWORD 'initial_password'; \ + GRANT ALL PRIVILEGES ON DATABASE pgdog TO pgdog_static;" \ + 2>/dev/null || true + +echo "Configuring Vault static database role..." +$VAULT write database/static-roles/pgdog-static-role \ + db_name=pgdog \ + rotation_statements="ALTER USER \"{{name}}\" WITH PASSWORD '{{password}}';" \ + username="pgdog_static" \ + rotation_period=3600 + echo "Configuring AppRole auth..." $VAULT auth enable approle 2>/dev/null || true @@ -40,6 +53,9 @@ $VAULT policy write pgdog-policy - <<'EOF' path "database/creds/pgdog-role" { capabilities = ["read"] } +path "database/static-creds/pgdog-static-role" { + capabilities = ["read"] +} EOF $VAULT write auth/approle/role/pgdog-role \ @@ -49,6 +65,7 @@ $VAULT write auth/approle/role/pgdog-role \ ROLE_ID=$($VAULT read -field=role_id auth/approle/role/pgdog-role/role-id) SECRET_ID=$($VAULT write -f -field=secret_id auth/approle/role/pgdog-role/secret-id) +STATIC_PWD=$($VAULT read -field=password database/static-creds/pgdog-static-role) echo "$SECRET_ID" > "$SCRIPT_DIR/vault-secret-id" echo "Written vault-secret-id" @@ -78,5 +95,7 @@ EOF echo "Generated pgdog.toml with role_id=$ROLE_ID" echo "" +echo "Static role password is $STATIC_PWD" +echo "" echo "Run pgdog with:" echo " cargo run -- --config $SCRIPT_DIR/pgdog.toml --users $SCRIPT_DIR/users.toml" diff --git a/integration/vault/users.toml b/integration/vault/users.toml index a669059d4..2a8fe5410 100644 --- a/integration/vault/users.toml +++ b/integration/vault/users.toml @@ -4,3 +4,12 @@ database = "pgdog" password = "pgdog" server_auth = "vault" vault_path = "database/creds/pgdog-role" + +# Client password verified against Vault static role; backend uses the +# existing dynamic role so no static Postgres credentials are stored here. +[[users]] +name = "pgdog_static" +database = "pgdog" +client_vault_path = "database/static-creds/pgdog-static-role" +server_auth = "vault" +vault_path = "database/creds/pgdog-role" diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 37233f322..8bef422f7 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -88,6 +88,13 @@ impl Users { ); } + if user.client_vault_path.is_some() && config.vault.is_none() { + warn!( + r#"user "{}" (database "{}") uses Vault client auth but the [vault] section is missing from pgdog.toml"#, + user.name, user.database + ); + } + if user.server_auth == ServerAuth::Vault { if user.vault_path.is_none() { warn!( @@ -197,6 +204,12 @@ impl ServerAuth { pub enum PasswordKind { Plain(String), Hashed(String), + /// Verify the client's password against a Vault static database role. + /// + /// The inner `String` is the Vault path (e.g. `database/static-creds/my-role`). + /// Resolved to a [`Plain`](Self::Plain) password at authentication time; + /// never passed to the underlying md5 / SCRAM / plain verifiers directly. + VaultStaticRole(String), } impl PasswordKind { @@ -204,6 +217,7 @@ impl PasswordKind { match self { Self::Plain(plain) => plain.as_str(), Self::Hashed(hash) => hash.as_str(), + Self::VaultStaticRole(path) => path.as_str(), } } } @@ -213,6 +227,7 @@ impl Display for PasswordKind { match self { Self::Plain(plain) => write!(f, "{}", plain), Self::Hashed(hashed) => write!(f, "{}", hashed), + Self::VaultStaticRole(path) => write!(f, "{}", path), } } } @@ -289,6 +304,11 @@ pub struct User { /// /// _Default:_ `80` pub vault_refresh_percent: Option, + /// Vault path to a static database role used to verify client passwords, + /// e.g. `database/static-creds/my-role`. When set, PgDog fetches the + /// current password from Vault and compares it to what the client + /// provides instead of using a statically configured password. + pub client_vault_path: Option, /// Statement timeout. /// /// Sets the `statement_timeout` on all server connections at connection creation. This allows you to set a reasonable default for each user without modifying `postgresql.conf` or using `ALTER USER`. @@ -364,11 +384,12 @@ impl User { if !self.password().is_empty() { passwords.push(PasswordKind::Plain(self.password().to_string())); } - if let Some(hash) = self.password_hash.clone() { passwords.push(PasswordKind::Hashed(hash)); } - + if let Some(path) = self.client_vault_path.clone() { + passwords.push(PasswordKind::VaultStaticRole(path)); + } passwords } diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index ad2f1dc3f..36afda3b8 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -1,12 +1,18 @@ -//! HashiCorp Vault dynamic database credentials. +//! HashiCorp Vault dynamic database credentials (backend pools) and static +//! role password verification (client authentication). //! -//! Pools configured with `server_auth = "vault"` fetch their username and -//! password from the Vault path configured on the user -//! (e.g. `database/creds/my-role`). Credentials are cached in the global -//! [`TokenCache`](crate::backend::pool::token_cache::TokenCache) and -//! refreshed by the pool monitor after a configured percentage of the -//! lease has elapsed. +//! **Dynamic credentials** — pools configured with `server_auth = "vault"` +//! fetch their username and password from a Vault database secrets engine +//! path (e.g. `database/creds/my-role`). Credentials are cached in the +//! global [`TokenCache`](crate::backend::pool::token_cache::TokenCache) and +//! refreshed by the pool monitor after a configured percentage of the lease. +//! +//! **Static role passwords** — users configured with `client_vault_path` +//! (e.g. `database/static-creds/my-role`) have their client-supplied +//! password verified against the current password held by Vault for that +//! static role. Results are cached for the role's rotation TTL. +use std::collections::HashMap; use std::time::{Duration, SystemTime}; use once_cell::sync::Lazy; @@ -29,6 +35,20 @@ struct VaultToken { /// Cached Vault client token, shared by all pools. static VAULT_TOKEN: Lazy>> = Lazy::new(|| Mutex::new(None)); +// ── Static role client-auth cache ──────────────────────────────────────────── + +/// How early to evict a static role password before its rotation TTL expires, +/// to reduce the chance of serving a stale password right at the rotation boundary. +const STATIC_CACHE_BUFFER: Duration = Duration::from_secs(10); + +struct CachedStaticPassword { + password: String, + expires_at: SystemTime, +} + +static CLIENT_PASSWORD_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + #[derive(Deserialize)] struct AuthResponse { auth: AuthData, @@ -52,6 +72,22 @@ struct SecretData { password: String, } +/// Response from a Vault static database role (`database/static-creds/`). +/// +/// Unlike dynamic leases, `lease_duration` is always 0; `data.ttl` is the +/// seconds remaining until Vault rotates the password. +#[derive(Deserialize)] +struct StaticSecretResponse { + data: StaticSecretData, +} + +#[derive(Deserialize)] +struct StaticSecretData { + password: String, + /// Seconds until Vault rotates the password. + ttl: u64, +} + fn error(message: impl std::fmt::Display) -> Error { Error::VaultCredentials(message.to_string()) } @@ -259,6 +295,85 @@ pub(crate) async fn credentials(addr: Address) -> Result Result { + if let Some(cached) = CLIENT_PASSWORD_CACHE.lock().get(vault_path) { + if SystemTime::now() < cached.expires_at { + return Ok(cached.password.clone()); + } + } + + let vault = config() + .config + .vault + .clone() + .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; + + let token = vault_token(&vault).await?; + let url = format!( + "{}/v1/{}", + vault.url.trim_end_matches('/'), + vault_path.trim_start_matches('/') + ); + + let response = client(&vault)? + .get(&url) + .header("X-Vault-Token", token) + .send() + .await + .map_err(|err| { + error(format!( + "Vault static credentials request to \"{}\" failed: {}", + url, err + )) + })?; + + let status = response.status(); + + if status == reqwest::StatusCode::FORBIDDEN { + *VAULT_TOKEN.lock() = None; + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(error(format!( + "Vault static credentials read at \"{}\" returned {}: {}", + url, status, body + ))); + } + + let secret: StaticSecretResponse = response.json().await.map_err(|err| { + error(format!( + "invalid Vault static credentials response: {}", + err + )) + })?; + + let ttl = Duration::from_secs(secret.data.ttl); + let expires_at = SystemTime::now() + ttl.saturating_sub(STATIC_CACHE_BUFFER); + + debug!( + vault_path, + ttl_secs = secret.data.ttl, + "fetched Vault static role password" + ); + + CLIENT_PASSWORD_CACHE.lock().insert( + vault_path.to_owned(), + CachedStaticPassword { + password: secret.data.password.clone(), + expires_at, + }, + ); + + Ok(secret.data.password) +} + #[cfg(test)] mod tests { use std::time::{Duration, SystemTime}; diff --git a/pgdog/src/backend/pool/connection/mod.rs b/pgdog/src/backend/pool/connection/mod.rs index 95d144438..ffcf70afd 100644 --- a/pgdog/src/backend/pool/connection/mod.rs +++ b/pgdog/src/backend/pool/connection/mod.rs @@ -380,6 +380,9 @@ impl Connection { PasswordKind::Plain(plain) => { user.passwords.push(plain.clone()); } + + // Vault static roles are for client auth only; skip for passthrough. + PasswordKind::VaultStaticRole(_) => {} } } diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 87606e82e..76ebe7d12 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -160,6 +160,29 @@ impl Client { return Ok(AuthResult::NoPasswordConfig); } + // Resolve any Vault static role entries to plaintext before starting + // the auth exchange. MD5, SCRAM, and plain all need the actual + // password, so this must happen before the first message is sent. + let resolved; + let passwords: &[PasswordKind] = + if passwords.iter().any(|p| matches!(p, PasswordKind::VaultStaticRole(_))) { + let mut buf = Vec::with_capacity(passwords.len()); + for p in passwords { + match p { + PasswordKind::VaultStaticRole(path) => { + let pw = + crate::backend::auth::vault::static_client_password(path).await?; + buf.push(PasswordKind::Plain(pw)); + } + other => buf.push(other.clone()), + } + } + resolved = buf; + &resolved + } else { + passwords + }; + let result = match auth_type { AuthType::Md5 => { let md5 = md5::Client::new( From 7905f0430a61656e3be97300d881842700e0930a Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Wed, 1 Jul 2026 12:04:44 +0200 Subject: [PATCH 2/9] refactor --- pgdog/src/auth/mod.rs | 1 + pgdog/src/auth/vault.rs | 634 +++++++++++++++++++++++++++++++ pgdog/src/backend/auth/vault.rs | 432 +-------------------- pgdog/src/frontend/client/mod.rs | 33 +- 4 files changed, 666 insertions(+), 434 deletions(-) create mode 100644 pgdog/src/auth/vault.rs diff --git a/pgdog/src/auth/mod.rs b/pgdog/src/auth/mod.rs index 94835d1e9..da07a343f 100644 --- a/pgdog/src/auth/mod.rs +++ b/pgdog/src/auth/mod.rs @@ -4,6 +4,7 @@ pub mod auth_result; pub mod error; pub mod md5; pub mod scram; +pub mod vault; pub use auth_result::AuthResult; pub use error::Error; diff --git a/pgdog/src/auth/vault.rs b/pgdog/src/auth/vault.rs new file mode 100644 index 000000000..4f796ccff --- /dev/null +++ b/pgdog/src/auth/vault.rs @@ -0,0 +1,634 @@ +//! HashiCorp Vault client and static role password verification. +//! +//! This module owns the Vault login/token machinery shared by every Vault +//! consumer in the codebase: +//! +//! - **Client authentication** — users configured with `client_vault_path` +//! (e.g. `database/static-creds/my-role`) have their client-supplied +//! password verified against the current password held by Vault for that +//! static role. Results are cached for the role's rotation TTL. +//! - **Backend pools** (`src/backend/auth/vault.rs`) reuse the login/token +//! cache here to fetch dynamic database credentials. + +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::Deserialize; +use serde_json::json; +use tracing::debug; + +use crate::backend::Error; +use crate::config::config; +use pgdog_config::vault::{Vault, VaultAuthMethod}; + +#[derive(Clone, Debug)] +pub(crate) struct VaultToken { + pub(crate) token: String, + pub(crate) expires_at: SystemTime, +} + +/// Cached Vault client token, shared by all pools. +pub(crate) static VAULT_TOKEN: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// ── Static role client-auth cache ──────────────────────────────────────────── + +/// How early to evict a static role password before its rotation TTL expires, +/// to reduce the chance of serving a stale password right at the rotation boundary. +const STATIC_CACHE_BUFFER: Duration = Duration::from_secs(10); + +struct CachedStaticPassword { + password: String, + expires_at: SystemTime, +} + +static CLIENT_PASSWORD_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +#[derive(Deserialize)] +struct AuthResponse { + auth: AuthData, +} + +#[derive(Deserialize)] +struct AuthData { + client_token: String, + lease_duration: u64, +} + +/// Response from a Vault static database role (`database/static-creds/`). +/// +/// Unlike dynamic leases, `lease_duration` is always 0; `data.ttl` is the +/// seconds remaining until Vault rotates the password. +#[derive(Deserialize)] +struct StaticSecretResponse { + data: StaticSecretData, +} + +#[derive(Deserialize)] +struct StaticSecretData { + password: String, + /// Seconds until Vault rotates the password. + ttl: u64, +} + +pub(crate) fn error(message: impl std::fmt::Display) -> Error { + Error::VaultCredentials(message.to_string()) +} + +pub(crate) fn client(vault: &Vault) -> Result { + let mut builder = reqwest::Client::builder(); + + if let Some(namespace) = vault.namespace.as_deref() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "X-Vault-Namespace", + namespace + .parse() + .map_err(|_| error("invalid Vault namespace"))?, + ); + builder = builder.default_headers(headers); + } + + builder.build().map_err(error) +} + +pub(crate) async fn login(vault: &Vault) -> Result { + let mount = vault.auth_mount(); + let url = format!( + "{}/v1/auth/{}/login", + vault.url.trim_end_matches('/'), + mount + ); + + let payload = match vault.auth_method { + VaultAuthMethod::Kubernetes => { + let role = vault.kubernetes_role.as_deref().ok_or_else(|| { + error(r#""kubernetes_role" is required for Vault Kubernetes auth"#) + })?; + let jwt = tokio::fs::read_to_string(vault.kubernetes_jwt_path()) + .await + .map_err(|err| { + error(format!( + "failed to read service account JWT from \"{}\": {}", + vault.kubernetes_jwt_path(), + err + )) + })?; + json!({ "jwt": jwt.trim(), "role": role }) + } + + VaultAuthMethod::Approle => { + let role_id = vault + .approle_role_id + .as_deref() + .ok_or_else(|| error(r#""approle_role_id" is required for Vault AppRole auth"#))?; + let secret_id = match vault.approle_secret_id_file.as_deref() { + Some(path) => tokio::fs::read_to_string(path) + .await + .map(|secret| secret.trim().to_owned()) + .map_err(|err| { + error(format!( + "failed to read AppRole secret ID from \"{}\": {}", + path, err + )) + })?, + None => std::env::var("VAULT_SECRET_ID").map_err(|_| { + error( + r#"set "approle_secret_id_file" or the VAULT_SECRET_ID environment variable"#, + ) + })?, + }; + json!({ "role_id": role_id, "secret_id": secret_id }) + } + }; + + let response = client(vault)? + .post(&url) + .json(&payload) + .send() + .await + .map_err(|err| { + error(format!( + "Vault login request to \"{}\" failed: {}", + url, err + )) + })?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(error(format!( + "Vault login at \"{}\" returned {}: {}", + url, status, body + ))); + } + + let auth: AuthResponse = response + .json() + .await + .map_err(|err| error(format!("invalid Vault login response: {}", err)))?; + + Ok(VaultToken { + token: auth.auth.client_token, + expires_at: SystemTime::now() + Duration::from_secs(auth.auth.lease_duration), + }) +} + +/// Get a valid Vault client token, logging in if the cached one is +/// missing or about to expire. +pub(crate) async fn vault_token(vault: &Vault) -> Result { + if let Some(cached) = VAULT_TOKEN.lock().clone() + && SystemTime::now() + vault.token_expiry_buffer() < cached.expires_at + { + return Ok(cached.token); + } + + let token = login(vault).await?; + let secret = token.token.clone(); + *VAULT_TOKEN.lock() = Some(token); + debug!("logged into Vault"); + + Ok(secret) +} + +/// Return the current password for a Vault static database role, using a +/// per-path cache keyed by `vault_path`. +/// +/// The cached value is evicted `STATIC_CACHE_BUFFER` before the role's +/// rotation TTL expires so the next connection after a rotation picks up +/// the new password. +pub(crate) async fn static_client_password(vault_path: &str) -> Result { + if let Some(cached) = CLIENT_PASSWORD_CACHE.lock().get(vault_path) { + if SystemTime::now() < cached.expires_at { + return Ok(cached.password.clone()); + } + } + + let vault = config() + .config + .vault + .clone() + .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; + + let token = vault_token(&vault).await?; + let url = format!( + "{}/v1/{}", + vault.url.trim_end_matches('/'), + vault_path.trim_start_matches('/') + ); + + let response = client(&vault)? + .get(&url) + .header("X-Vault-Token", token) + .send() + .await + .map_err(|err| { + error(format!( + "Vault static credentials request to \"{}\" failed: {}", + url, err + )) + })?; + + let status = response.status(); + + if status == reqwest::StatusCode::FORBIDDEN { + *VAULT_TOKEN.lock() = None; + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(error(format!( + "Vault static credentials read at \"{}\" returned {}: {}", + url, status, body + ))); + } + + let secret: StaticSecretResponse = response.json().await.map_err(|err| { + error(format!( + "invalid Vault static credentials response: {}", + err + )) + })?; + + let ttl = Duration::from_secs(secret.data.ttl); + let expires_at = SystemTime::now() + ttl.saturating_sub(STATIC_CACHE_BUFFER); + + debug!( + vault_path, + ttl_secs = secret.data.ttl, + "fetched Vault static role password" + ); + + CLIENT_PASSWORD_CACHE.lock().insert( + vault_path.to_owned(), + CachedStaticPassword { + password: secret.data.password.clone(), + expires_at, + }, + ); + + Ok(secret.data.password) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, SystemTime}; + + use pgdog_config::vault::{Vault, VaultAuthMethod}; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::*; + use crate::config::ConfigAndUsers; + + fn setup() { + let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default(); + } + + fn approle_vault(url: &str) -> Vault { + Vault { + url: url.to_string(), + namespace: None, + auth_method: VaultAuthMethod::Approle, + auth_mount: None, + kubernetes_role: None, + kubernetes_jwt_path: None, + approle_role_id: Some("test-role-id".into()), + approle_secret_id_file: None, + client_token_ttl: None, + } + } + + fn set_vault_config(vault: Vault) { + let mut config = ConfigAndUsers::default(); + config.config.vault = Some(vault); + crate::config::set(config).unwrap(); + } + + // ── static_client_password(): config-level error cases ───────────────────── + + #[tokio::test] + async fn test_static_client_password_no_vault_config() { + crate::config::set(ConfigAndUsers::default()).unwrap(); + + let err = static_client_password("database/static-creds/no-such-role") + .await + .unwrap_err(); + assert!( + err.to_string().contains("[vault] section is missing"), + "unexpected error: {err}" + ); + } + + // ── static_client_password(): cache behaviour ─────────────────────────────── + + #[tokio::test] + async fn test_static_client_password_uses_cache() { + CLIENT_PASSWORD_CACHE.lock().insert( + "database/static-creds/cached-role".into(), + CachedStaticPassword { + password: "cached-pw".into(), + expires_at: SystemTime::now() + Duration::from_secs(3600), + }, + ); + + // No Vault config is set; proves the cache hit short-circuits + // before any config lookup or HTTP call. + let pw = static_client_password("database/static-creds/cached-role") + .await + .unwrap(); + assert_eq!(pw, "cached-pw"); + } + + #[tokio::test] + async fn test_static_client_password_expired_cache_refetches() { + setup(); + let server = MockServer::start().await; + + CLIENT_PASSWORD_CACHE.lock().insert( + "database/static-creds/expired-role".into(), + CachedStaticPassword { + password: "stale-pw".into(), + expires_at: SystemTime::now() - Duration::from_secs(1), + }, + ); + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.tok3", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/expired-role")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "password": "fresh-pw", "ttl": 3600 } + }))) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + set_vault_config(approle_vault(&server.uri())); + + let pw = static_client_password("database/static-creds/expired-role") + .await + .unwrap(); + assert_eq!(pw, "fresh-pw"); + } + + // ── static_client_password(): HTTP responses ──────────────────────────────── + + #[tokio::test] + async fn test_static_client_password_success() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.tok", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/pgdog-static-role")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "password": "rotated-secret", "ttl": 3600 } + }))) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + set_vault_config(approle_vault(&server.uri())); + + let pw = static_client_password("database/static-creds/pgdog-static-role") + .await + .unwrap(); + assert_eq!(pw, "rotated-secret"); + assert_eq!( + CLIENT_PASSWORD_CACHE + .lock() + .get("database/static-creds/pgdog-static-role") + .unwrap() + .password, + "rotated-secret" + ); + } + + #[tokio::test] + async fn test_static_client_password_forbidden_clears_token_cache() { + setup(); + let server = MockServer::start().await; + + *VAULT_TOKEN.lock() = Some(VaultToken { + token: "s.stale".into(), + expires_at: SystemTime::now() + Duration::from_secs(3600), + }); + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/forbidden-role")) + .respond_with(ResponseTemplate::new(403).set_body_string("token revoked")) + .mount(&server) + .await; + + set_vault_config(approle_vault(&server.uri())); + + let err = static_client_password("database/static-creds/forbidden-role") + .await + .unwrap_err(); + assert!(err.to_string().contains("403"), "unexpected error: {err}"); + assert!( + VAULT_TOKEN.lock().is_none(), + "token cache should be cleared after 403" + ); + } + + #[tokio::test] + async fn test_static_client_password_error_response() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.tok2", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/error-role")) + .respond_with(ResponseTemplate::new(500).set_body_string("internal error")) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + set_vault_config(approle_vault(&server.uri())); + + let err = static_client_password("database/static-creds/error-role") + .await + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("500"), "expected 500 in: {msg}"); + assert!(msg.contains("internal error"), "expected body in: {msg}"); + } + + // ── login(): parameter validation ───────────────────────────────────────── + + #[tokio::test] + async fn test_login_approle_missing_role_id() { + let vault = Vault { + url: "http://127.0.0.1:8200".into(), + namespace: None, + auth_method: VaultAuthMethod::Approle, + auth_mount: None, + kubernetes_role: None, + kubernetes_jwt_path: None, + approle_role_id: None, + approle_secret_id_file: None, + client_token_ttl: None, + }; + + let err = login(&vault).await.unwrap_err(); + assert!( + err.to_string().contains("approle_role_id"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_login_approle_missing_secret_id() { + let _guard = crate::test_utils::remove_env_var("VAULT_SECRET_ID"); + + let vault = Vault { + url: "http://127.0.0.1:8200".into(), + namespace: None, + auth_method: VaultAuthMethod::Approle, + auth_mount: None, + kubernetes_role: None, + kubernetes_jwt_path: None, + approle_role_id: Some("my-role".into()), + approle_secret_id_file: None, + client_token_ttl: None, + }; + + let err = login(&vault).await.unwrap_err(); + assert!( + err.to_string().contains("VAULT_SECRET_ID"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_login_kubernetes_missing_role() { + let vault = Vault { + url: "http://127.0.0.1:8200".into(), + namespace: None, + auth_method: VaultAuthMethod::Kubernetes, + auth_mount: None, + kubernetes_role: None, + kubernetes_jwt_path: None, + approle_role_id: None, + approle_secret_id_file: None, + client_token_ttl: None, + }; + + let err = login(&vault).await.unwrap_err(); + assert!( + err.to_string().contains("kubernetes_role"), + "unexpected error: {err}" + ); + } + + // ── login(): HTTP responses ──────────────────────────────────────────────── + + #[tokio::test] + async fn test_login_approle_success() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.abc123", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + let vault = approle_vault(&server.uri()); + + let token = login(&vault).await.unwrap(); + assert_eq!(token.token, "s.abc123"); + assert!(token.expires_at > SystemTime::now()); + } + + #[tokio::test] + async fn test_login_non_success_response() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(403).set_body_string("permission denied")) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "bad-secret"); + let vault = approle_vault(&server.uri()); + + let err = login(&vault).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("403"), "expected 403 in: {msg}"); + assert!(msg.contains("permission denied"), "expected body in: {msg}"); + } + + // ── vault_token(): cache behaviour ──────────────────────────────────────── + + #[tokio::test] + async fn test_vault_token_uses_cached() { + *VAULT_TOKEN.lock() = Some(VaultToken { + token: "s.cached".into(), + expires_at: SystemTime::now() + Duration::from_secs(3600), + }); + + // Port 1 is unreachable; proves no HTTP call was made. + let vault = approle_vault("http://127.0.0.1:1"); + let tok = vault_token(&vault).await.unwrap(); + assert_eq!(tok, "s.cached"); + } + + // ── client(): namespace header ───────────────────────────────────────────── + + #[test] + fn test_client_with_namespace() { + setup(); + let vault = Vault { + url: "http://127.0.0.1:8200".into(), + namespace: Some("ns1/ns2".into()), + auth_method: VaultAuthMethod::Approle, + auth_mount: None, + kubernetes_role: None, + kubernetes_jwt_path: None, + approle_role_id: None, + approle_secret_id_file: None, + client_token_ttl: None, + }; + assert!(client(&vault).is_ok()); + } + + #[test] + fn test_client_without_namespace() { + setup(); + assert!(client(&approle_vault("http://127.0.0.1:8200")).is_ok()); + } +} diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 36afda3b8..f6d744801 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -1,64 +1,26 @@ -//! HashiCorp Vault dynamic database credentials (backend pools) and static -//! role password verification (client authentication). +//! HashiCorp Vault dynamic database credentials for backend pools. //! -//! **Dynamic credentials** — pools configured with `server_auth = "vault"` -//! fetch their username and password from a Vault database secrets engine -//! path (e.g. `database/creds/my-role`). Credentials are cached in the -//! global [`TokenCache`](crate::backend::pool::token_cache::TokenCache) and -//! refreshed by the pool monitor after a configured percentage of the lease. +//! Pools configured with `server_auth = "vault"` fetch their username and +//! password from a Vault database secrets engine path (e.g. +//! `database/creds/my-role`). Credentials are cached in the global +//! [`TokenCache`](crate::backend::pool::token_cache::TokenCache) and +//! refreshed by the pool monitor after a configured percentage of the +//! lease. //! -//! **Static role passwords** — users configured with `client_vault_path` -//! (e.g. `database/static-creds/my-role`) have their client-supplied -//! password verified against the current password held by Vault for that -//! static role. Results are cached for the role's rotation TTL. +//! The Vault login/token cache itself lives in +//! [`crate::auth::vault`], shared with static role client-auth +//! verification. -use std::collections::HashMap; use std::time::{Duration, SystemTime}; -use once_cell::sync::Lazy; -use parking_lot::Mutex; use serde::Deserialize; -use serde_json::json; -use tracing::{debug, info}; +use tracing::info; +use crate::auth::vault::{VAULT_TOKEN, client, error, vault_token}; use crate::backend::pool::token_cache::{Credentials, FetchedCredentials}; use crate::backend::{Error, pool::Address}; use crate::config::config; -use pgdog_config::vault::{DEFAULT_REFRESH_PERCENT, Vault, VaultAuthMethod}; - -#[derive(Clone, Debug)] -struct VaultToken { - token: String, - expires_at: SystemTime, -} - -/// Cached Vault client token, shared by all pools. -static VAULT_TOKEN: Lazy>> = Lazy::new(|| Mutex::new(None)); - -// ── Static role client-auth cache ──────────────────────────────────────────── - -/// How early to evict a static role password before its rotation TTL expires, -/// to reduce the chance of serving a stale password right at the rotation boundary. -const STATIC_CACHE_BUFFER: Duration = Duration::from_secs(10); - -struct CachedStaticPassword { - password: String, - expires_at: SystemTime, -} - -static CLIENT_PASSWORD_CACHE: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -#[derive(Deserialize)] -struct AuthResponse { - auth: AuthData, -} - -#[derive(Deserialize)] -struct AuthData { - client_token: String, - lease_duration: u64, -} +use pgdog_config::vault::DEFAULT_REFRESH_PERCENT; #[derive(Deserialize)] struct SecretResponse { @@ -72,142 +34,6 @@ struct SecretData { password: String, } -/// Response from a Vault static database role (`database/static-creds/`). -/// -/// Unlike dynamic leases, `lease_duration` is always 0; `data.ttl` is the -/// seconds remaining until Vault rotates the password. -#[derive(Deserialize)] -struct StaticSecretResponse { - data: StaticSecretData, -} - -#[derive(Deserialize)] -struct StaticSecretData { - password: String, - /// Seconds until Vault rotates the password. - ttl: u64, -} - -fn error(message: impl std::fmt::Display) -> Error { - Error::VaultCredentials(message.to_string()) -} - -fn client(vault: &Vault) -> Result { - let mut builder = reqwest::Client::builder(); - - if let Some(namespace) = vault.namespace.as_deref() { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - "X-Vault-Namespace", - namespace - .parse() - .map_err(|_| error("invalid Vault namespace"))?, - ); - builder = builder.default_headers(headers); - } - - builder.build().map_err(error) -} - -async fn login(vault: &Vault) -> Result { - let mount = vault.auth_mount(); - let url = format!( - "{}/v1/auth/{}/login", - vault.url.trim_end_matches('/'), - mount - ); - - let payload = match vault.auth_method { - VaultAuthMethod::Kubernetes => { - let role = vault.kubernetes_role.as_deref().ok_or_else(|| { - error(r#""kubernetes_role" is required for Vault Kubernetes auth"#) - })?; - let jwt = tokio::fs::read_to_string(vault.kubernetes_jwt_path()) - .await - .map_err(|err| { - error(format!( - "failed to read service account JWT from \"{}\": {}", - vault.kubernetes_jwt_path(), - err - )) - })?; - json!({ "jwt": jwt.trim(), "role": role }) - } - - VaultAuthMethod::Approle => { - let role_id = vault - .approle_role_id - .as_deref() - .ok_or_else(|| error(r#""approle_role_id" is required for Vault AppRole auth"#))?; - let secret_id = match vault.approle_secret_id_file.as_deref() { - Some(path) => tokio::fs::read_to_string(path) - .await - .map(|secret| secret.trim().to_owned()) - .map_err(|err| { - error(format!( - "failed to read AppRole secret ID from \"{}\": {}", - path, err - )) - })?, - None => std::env::var("VAULT_SECRET_ID").map_err(|_| { - error( - r#"set "approle_secret_id_file" or the VAULT_SECRET_ID environment variable"#, - ) - })?, - }; - json!({ "role_id": role_id, "secret_id": secret_id }) - } - }; - - let response = client(vault)? - .post(&url) - .json(&payload) - .send() - .await - .map_err(|err| { - error(format!( - "Vault login request to \"{}\" failed: {}", - url, err - )) - })?; - - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(error(format!( - "Vault login at \"{}\" returned {}: {}", - url, status, body - ))); - } - - let auth: AuthResponse = response - .json() - .await - .map_err(|err| error(format!("invalid Vault login response: {}", err)))?; - - Ok(VaultToken { - token: auth.auth.client_token, - expires_at: SystemTime::now() + Duration::from_secs(auth.auth.lease_duration), - }) -} - -/// Get a valid Vault client token, logging in if the cached one is -/// missing or about to expire. -async fn vault_token(vault: &Vault) -> Result { - if let Some(cached) = VAULT_TOKEN.lock().clone() - && SystemTime::now() + vault.token_expiry_buffer() < cached.expires_at - { - return Ok(cached.token); - } - - let token = login(vault).await?; - let secret = token.token.clone(); - *VAULT_TOKEN.lock() = Some(token); - debug!("logged into Vault"); - - Ok(secret) -} - /// Fetch fresh dynamic database credentials for `addr` from Vault. /// /// This is the raw fetcher passed to [`TokenCache::credentials_or_fetch`] @@ -295,89 +121,8 @@ pub(crate) async fn credentials(addr: Address) -> Result Result { - if let Some(cached) = CLIENT_PASSWORD_CACHE.lock().get(vault_path) { - if SystemTime::now() < cached.expires_at { - return Ok(cached.password.clone()); - } - } - - let vault = config() - .config - .vault - .clone() - .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; - - let token = vault_token(&vault).await?; - let url = format!( - "{}/v1/{}", - vault.url.trim_end_matches('/'), - vault_path.trim_start_matches('/') - ); - - let response = client(&vault)? - .get(&url) - .header("X-Vault-Token", token) - .send() - .await - .map_err(|err| { - error(format!( - "Vault static credentials request to \"{}\" failed: {}", - url, err - )) - })?; - - let status = response.status(); - - if status == reqwest::StatusCode::FORBIDDEN { - *VAULT_TOKEN.lock() = None; - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(error(format!( - "Vault static credentials read at \"{}\" returned {}: {}", - url, status, body - ))); - } - - let secret: StaticSecretResponse = response.json().await.map_err(|err| { - error(format!( - "invalid Vault static credentials response: {}", - err - )) - })?; - - let ttl = Duration::from_secs(secret.data.ttl); - let expires_at = SystemTime::now() + ttl.saturating_sub(STATIC_CACHE_BUFFER); - - debug!( - vault_path, - ttl_secs = secret.data.ttl, - "fetched Vault static role password" - ); - - CLIENT_PASSWORD_CACHE.lock().insert( - vault_path.to_owned(), - CachedStaticPassword { - password: secret.data.password.clone(), - expires_at, - }, - ); - - Ok(secret.data.password) -} - #[cfg(test)] mod tests { - use std::time::{Duration, SystemTime}; - use pgdog_config::Role; use pgdog_config::vault::{Vault, VaultAuthMethod}; use serde_json::json; @@ -385,6 +130,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; + use crate::auth::vault::VaultToken; use crate::config::ConfigAndUsers; fn setup() { @@ -453,131 +199,6 @@ mod tests { ); } - // ── login(): parameter validation ───────────────────────────────────────── - - #[tokio::test] - async fn test_login_approle_missing_role_id() { - let vault = Vault { - url: "http://127.0.0.1:8200".into(), - namespace: None, - auth_method: VaultAuthMethod::Approle, - auth_mount: None, - kubernetes_role: None, - kubernetes_jwt_path: None, - approle_role_id: None, - approle_secret_id_file: None, - client_token_ttl: None, - }; - - let err = login(&vault).await.unwrap_err(); - assert!( - err.to_string().contains("approle_role_id"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn test_login_approle_missing_secret_id() { - let _guard = crate::test_utils::remove_env_var("VAULT_SECRET_ID"); - - let vault = Vault { - url: "http://127.0.0.1:8200".into(), - namespace: None, - auth_method: VaultAuthMethod::Approle, - auth_mount: None, - kubernetes_role: None, - kubernetes_jwt_path: None, - approle_role_id: Some("my-role".into()), - approle_secret_id_file: None, - client_token_ttl: None, - }; - - let err = login(&vault).await.unwrap_err(); - assert!( - err.to_string().contains("VAULT_SECRET_ID"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn test_login_kubernetes_missing_role() { - let vault = Vault { - url: "http://127.0.0.1:8200".into(), - namespace: None, - auth_method: VaultAuthMethod::Kubernetes, - auth_mount: None, - kubernetes_role: None, - kubernetes_jwt_path: None, - approle_role_id: None, - approle_secret_id_file: None, - client_token_ttl: None, - }; - - let err = login(&vault).await.unwrap_err(); - assert!( - err.to_string().contains("kubernetes_role"), - "unexpected error: {err}" - ); - } - - // ── login(): HTTP responses ──────────────────────────────────────────────── - - #[tokio::test] - async fn test_login_approle_success() { - setup(); - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/v1/auth/approle/login")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "auth": { "client_token": "s.abc123", "lease_duration": 3600 } - }))) - .mount(&server) - .await; - - let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); - let vault = approle_vault(&server.uri()); - - let token = login(&vault).await.unwrap(); - assert_eq!(token.token, "s.abc123"); - assert!(token.expires_at > SystemTime::now()); - } - - #[tokio::test] - async fn test_login_non_success_response() { - setup(); - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/v1/auth/approle/login")) - .respond_with(ResponseTemplate::new(403).set_body_string("permission denied")) - .mount(&server) - .await; - - let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "bad-secret"); - let vault = approle_vault(&server.uri()); - - let err = login(&vault).await.unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("403"), "expected 403 in: {msg}"); - assert!(msg.contains("permission denied"), "expected body in: {msg}"); - } - - // ── vault_token(): cache behaviour ──────────────────────────────────────── - - #[tokio::test] - async fn test_vault_token_uses_cached() { - *VAULT_TOKEN.lock() = Some(VaultToken { - token: "s.cached".into(), - expires_at: SystemTime::now() + Duration::from_secs(3600), - }); - - // Port 1 is unreachable; proves no HTTP call was made. - let vault = approle_vault("http://127.0.0.1:1"); - let tok = vault_token(&vault).await.unwrap(); - assert_eq!(tok, "s.cached"); - } - // ── credentials(): HTTP responses ───────────────────────────────────────── #[tokio::test] @@ -673,29 +294,4 @@ mod tests { assert!(msg.contains("500"), "expected 500 in: {msg}"); assert!(msg.contains("internal error"), "expected body in: {msg}"); } - - // ── client(): namespace header ───────────────────────────────────────────── - - #[test] - fn test_client_with_namespace() { - setup(); - let vault = Vault { - url: "http://127.0.0.1:8200".into(), - namespace: Some("ns1/ns2".into()), - auth_method: VaultAuthMethod::Approle, - auth_mount: None, - kubernetes_role: None, - kubernetes_jwt_path: None, - approle_role_id: None, - approle_secret_id_file: None, - client_token_ttl: None, - }; - assert!(client(&vault).is_ok()); - } - - #[test] - fn test_client_without_namespace() { - setup(); - assert!(client(&approle_vault("http://127.0.0.1:8200")).is_ok()); - } } diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 76ebe7d12..dd83bf850 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -164,24 +164,25 @@ impl Client { // the auth exchange. MD5, SCRAM, and plain all need the actual // password, so this must happen before the first message is sent. let resolved; - let passwords: &[PasswordKind] = - if passwords.iter().any(|p| matches!(p, PasswordKind::VaultStaticRole(_))) { - let mut buf = Vec::with_capacity(passwords.len()); - for p in passwords { - match p { - PasswordKind::VaultStaticRole(path) => { - let pw = - crate::backend::auth::vault::static_client_password(path).await?; - buf.push(PasswordKind::Plain(pw)); - } - other => buf.push(other.clone()), + let passwords: &[PasswordKind] = if passwords + .iter() + .any(|p| matches!(p, PasswordKind::VaultStaticRole(_))) + { + let mut buf = Vec::with_capacity(passwords.len()); + for p in passwords { + match p { + PasswordKind::VaultStaticRole(path) => { + let pw = crate::auth::vault::static_client_password(path).await?; + buf.push(PasswordKind::Plain(pw)); } + other => buf.push(other.clone()), } - resolved = buf; - &resolved - } else { - passwords - }; + } + resolved = buf; + &resolved + } else { + passwords + }; let result = match auth_type { AuthType::Md5 => { From 4189aa53442e7cabee64fe51262f9db6c4e737d1 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Wed, 1 Jul 2026 13:13:30 +0200 Subject: [PATCH 3/9] backend --- .schema/pgdog.schema.json | 2 +- .schema/users.schema.json | 23 +-- README.md | 6 +- example.pgdog.toml | 2 +- example.users.toml | 6 +- integration/vault/users.toml | 12 +- pgdog-config/src/users.rs | 152 ++++++++++++++++--- pgdog-config/src/vault.rs | 5 +- pgdog/src/auth/vault.rs | 18 ++- pgdog/src/backend/auth/vault.rs | 237 +++++++++++++++++++++++++++++- pgdog/src/backend/pool/address.rs | 89 ++++++++++- pgdog/src/backend/pool/monitor.rs | 10 +- 12 files changed, 496 insertions(+), 66 deletions(-) diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index 90756da82..f9ceea231 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -2218,7 +2218,7 @@ ] }, "Vault": { - "description": "HashiCorp Vault settings, used by pools configured with `server_auth = \"vault\"`.\n\nPgDog logs into Vault using the configured auth method and fetches\ndynamic database credentials from the per-user `vault_path`.", + "description": "HashiCorp Vault settings, used by pools configured with `server_auth = \"vault_dynamic\"`\nor `\"vault_static\"`.\n\nPgDog logs into Vault using the configured auth method and fetches\ndatabase credentials from the per-user `backend_vault_path`.", "type": "object", "properties": { "approle_role_id": { diff --git a/.schema/users.schema.json b/.schema/users.schema.json index c0e468973..1ec28bbae 100644 --- a/.schema/users.schema.json +++ b/.schema/users.schema.json @@ -87,9 +87,14 @@ "const": "azure_workload_identity" }, { - "description": "Fetch dynamic credentials from HashiCorp Vault.", + "description": "Fetch dynamic credentials from HashiCorp Vault (database secrets engine).\nVault generates a new username and password on each lease.\n\n**Note:** `\"vault\"` is accepted as a deprecated alias for backward compatibility.", "type": "string", - "const": "vault" + "const": "vault_dynamic" + }, + { + "description": "Fetch credentials for a Vault static database role.\nVault manages password rotation; the username is fixed.", + "type": "string", + "const": "vault_static" } ] }, @@ -102,6 +107,13 @@ "type": "boolean", "default": false }, + "backend_vault_path": { + "description": "Vault path used to fetch backend (server-side) database credentials,\ne.g. `database/creds/my-role` for `server_auth = \"vault_dynamic\"` or\n`database/static-creds/my-role` for `server_auth = \"vault_static\"`.\n\n**Note:** `\"vault_path\"` is accepted as a deprecated alias for backward compatibility.", + "type": [ + "string", + "null" + ] + }, "client_vault_path": { "description": "Vault path to a static database role used to verify client passwords,\ne.g. `database/static-creds/my-role`. When set, PgDog fetches the\ncurrent password from Vault and compares it to what the client\nprovides instead of using a statically configured password.", "type": [ @@ -300,13 +312,6 @@ "null" ] }, - "vault_path": { - "description": "Vault path to fetch dynamic database credentials from, e.g. `database/creds/my-role`.\nRequired when `server_auth` is set to `vault`.", - "type": [ - "string", - "null" - ] - }, "vault_refresh_percent": { "description": "Percentage of the Vault credential lease after which credentials are refreshed.\n\n_Default:_ `80`", "type": [ diff --git a/README.md b/README.md index ad535ba62..80dbebfdf 100644 --- a/README.md +++ b/README.md @@ -266,8 +266,8 @@ In `users.toml`: name = "alice" database = "pgdog" password = "client-password" -server_auth = "vault" -vault_path = "database/creds/pgdog" +server_auth = "vault_dynamic" +backend_vault_path = "database/creds/pgdog" # Refresh credentials after 80% of the lease has elapsed (default). # vault_refresh_percent = 80 ``` @@ -283,7 +283,7 @@ kubernetes_role = "pgdog" PgDog logs into Vault with Kubernetes auth (using the pod's service account JWT) or AppRole (`approle_role_id` plus `approle_secret_id_file` or the `VAULT_SECRET_ID` environment variable). -When any user has `server_auth = "vault"`, the following settings must be configured as well: +When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the following settings must be configured as well: - `tls_verify` must **not** be `"disabled"`. - `passthrough_auth` must be `"disabled"`. diff --git a/example.pgdog.toml b/example.pgdog.toml index 68e42e551..96b2bb117 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -473,7 +473,7 @@ fingerprint = "2d9944fc9caeaadd" # [3285733254894627549] # exposure = 0.5 # Optional: overrides general.mirror_exposure # HashiCorp Vault settings, required when any user in users.toml -# sets `server_auth = "vault"`. +# sets `server_auth = "vault_dynamic"`. # # [vault] # url = "https://vault.internal:8200" diff --git a/example.users.toml b/example.users.toml index 6fb74814f..37009ff20 100644 --- a/example.users.toml +++ b/example.users.toml @@ -25,8 +25,8 @@ password = "pgdog" # Example: backend authentication with HashiCorp Vault dynamic credentials. # Requires the [vault] section in pgdog.toml. PgDog fetches a generated -# username and password from `vault_path` and rotates them after +# username and password from `backend_vault_path` and rotates them after # `vault_refresh_percent` of the lease has elapsed. -# server_auth = "vault" -# vault_path = "database/creds/pgdog" +# server_auth = "vault_dynamic" +# backend_vault_path = "database/creds/pgdog" # vault_refresh_percent = 80 # optional; default 80 diff --git a/integration/vault/users.toml b/integration/vault/users.toml index 2a8fe5410..cdb5992f3 100644 --- a/integration/vault/users.toml +++ b/integration/vault/users.toml @@ -2,14 +2,14 @@ name = "pgdog" database = "pgdog" password = "pgdog" -server_auth = "vault" -vault_path = "database/creds/pgdog-role" +server_auth = "vault_dynamic" +backend_vault_path = "database/creds/pgdog-role" -# Client password verified against Vault static role; backend uses the -# existing dynamic role so no static Postgres credentials are stored here. +# Client password verified against Vault static role; backend also uses the +# static role so the same Vault-managed password is used end-to-end. [[users]] name = "pgdog_static" database = "pgdog" client_vault_path = "database/static-creds/pgdog-static-role" -server_auth = "vault" -vault_path = "database/creds/pgdog-role" +server_auth = "vault_static" +backend_vault_path = "database/static-creds/pgdog-static-role" diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 8bef422f7..23d720c45 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -95,18 +95,21 @@ impl Users { ); } - if user.server_auth == ServerAuth::Vault { - if user.vault_path.is_none() { + if matches!( + user.server_auth, + ServerAuth::VaultDynamic | ServerAuth::VaultStatic + ) { + if user.backend_vault_path.is_none() { warn!( - r#"user "{}" (database "{}") uses "server_auth" = "vault" but "vault_path" is not set"#, - user.name, user.database + r#"user "{}" (database "{}") uses "server_auth" = "{:?}" but "backend_vault_path" is not set"#, + user.name, user.database, user.server_auth ); } if config.vault.is_none() { warn!( - r#"user "{}" (database "{}") uses "server_auth" = "vault" but the [vault] section is missing from pgdog.toml"#, - user.name, user.database + r#"user "{}" (database "{}") uses "server_auth" = "{:?}" but the [vault] section is missing from pgdog.toml"#, + user.name, user.database, user.server_auth ); } @@ -186,15 +189,22 @@ pub enum ServerAuth { RdsIam, /// Generate an Azure Workload Identity auth token per connection attempt. AzureWorkloadIdentity, - /// Fetch dynamic credentials from HashiCorp Vault. - Vault, + /// Fetch dynamic credentials from HashiCorp Vault (database secrets engine). + /// Vault generates a new username and password on each lease. + /// + /// **Note:** `"vault"` is accepted as a deprecated alias for backward compatibility. + #[serde(alias = "vault")] + VaultDynamic, + /// Fetch credentials for a Vault static database role. + /// Vault manages password rotation; the username is fixed. + VaultStatic, } impl ServerAuth { pub fn is_external_identity(&self) -> bool { matches!( self, - Self::RdsIam | Self::AzureWorkloadIdentity | Self::Vault + Self::RdsIam | Self::AzureWorkloadIdentity | Self::VaultDynamic | Self::VaultStatic ) } } @@ -297,9 +307,13 @@ pub struct User { pub server_auth: ServerAuth, /// Optional region override for RDS IAM token generation. pub server_iam_region: Option, - /// Vault path to fetch dynamic database credentials from, e.g. `database/creds/my-role`. - /// Required when `server_auth` is set to `vault`. - pub vault_path: Option, + /// Vault path used to fetch backend (server-side) database credentials, + /// e.g. `database/creds/my-role` for `server_auth = "vault_dynamic"` or + /// `database/static-creds/my-role` for `server_auth = "vault_static"`. + /// + /// **Note:** `"vault_path"` is accepted as a deprecated alias for backward compatibility. + #[serde(alias = "vault_path")] + pub backend_vault_path: Option, /// Percentage of the Vault credential lease after which credentials are refreshed. /// /// _Default:_ `80` @@ -692,32 +706,132 @@ server_auth = "azure_workload_identity" } #[test] - fn test_user_server_auth_vault() { + fn test_user_server_auth_vault_dynamic() { let source = r#" [[users]] name = "alice" database = "db" -server_auth = "vault" -vault_path = "database/creds/pgdog" +server_auth = "vault_dynamic" +backend_vault_path = "database/creds/pgdog" vault_refresh_percent = 75 "#; let users: Users = toml::from_str(source).unwrap(); let user = users.users.first().unwrap(); - assert_eq!(user.server_auth, ServerAuth::Vault); + assert_eq!(user.server_auth, ServerAuth::VaultDynamic); assert!(user.server_auth.is_external_identity()); - assert_eq!(user.vault_path.as_deref(), Some("database/creds/pgdog")); + assert_eq!( + user.backend_vault_path.as_deref(), + Some("database/creds/pgdog") + ); assert_eq!(user.vault_refresh_percent, Some(75)); } + #[test] + fn test_user_server_auth_vault_deprecated_alias() { + let source = r#" +[[users]] +name = "alice" +database = "db" +server_auth = "vault" +vault_path = "database/creds/pgdog" +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert_eq!(user.server_auth, ServerAuth::VaultDynamic); + } + + #[test] + fn test_user_backend_vault_path_deprecated_alias() { + let source = r#" +[[users]] +name = "alice" +database = "db" +server_auth = "vault_dynamic" +vault_path = "database/creds/pgdog" +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert_eq!( + user.backend_vault_path.as_deref(), + Some("database/creds/pgdog") + ); + } + + #[test] + fn test_user_server_auth_vault_static() { + let source = r#" +[[users]] +name = "alice" +database = "db" +server_auth = "vault_static" +backend_vault_path = "database/static-creds/my-role" +vault_refresh_percent = 60 +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert_eq!(user.server_auth, ServerAuth::VaultStatic); + assert!(user.server_auth.is_external_identity()); + assert_eq!( + user.backend_vault_path.as_deref(), + Some("database/static-creds/my-role") + ); + assert_eq!(user.vault_refresh_percent, Some(60)); + } + + #[test] + fn test_client_vault_path_appears_in_passwords_as_vault_static_role() { + let user = User { + name: "alice".into(), + database: "db".into(), + client_vault_path: Some("database/static-creds/alice-role".into()), + ..Default::default() + }; + + let passwords = user.passwords(); + assert_eq!(passwords.len(), 1); + assert!( + matches!(&passwords[0], PasswordKind::VaultStaticRole(p) if p == "database/static-creds/alice-role") + ); + } + + #[test] + fn test_client_vault_path_combined_with_static_password() { + let user = User { + name: "alice".into(), + database: "db".into(), + password: Some("fallback".into()), + client_vault_path: Some("database/static-creds/alice-role".into()), + ..Default::default() + }; + + let passwords = user.passwords(); + assert_eq!(passwords.len(), 2); + assert!( + passwords + .iter() + .any(|p| matches!(p, PasswordKind::Plain(s) if s == "fallback")) + ); + assert!(passwords.iter().any(|p| matches!(p, PasswordKind::VaultStaticRole(s) if s == "database/static-creds/alice-role"))); + } + + #[test] + fn test_vault_static_is_external_identity() { + assert!(ServerAuth::VaultStatic.is_external_identity()); + assert!(ServerAuth::VaultDynamic.is_external_identity()); + } + #[test] fn test_vault_refresh_percent_out_of_range_resets_to_default() { let mut users = Users { users: vec![User { name: "alice".into(), database: "db".into(), - server_auth: ServerAuth::Vault, - vault_path: Some("database/creds/pgdog".into()), + server_auth: ServerAuth::VaultDynamic, + backend_vault_path: Some("database/creds/pgdog".into()), vault_refresh_percent: Some(150), ..Default::default() }], diff --git a/pgdog-config/src/vault.rs b/pgdog-config/src/vault.rs index 3754b0cc3..307c22d93 100644 --- a/pgdog-config/src/vault.rs +++ b/pgdog-config/src/vault.rs @@ -26,10 +26,11 @@ pub enum VaultAuthMethod { Approle, } -/// HashiCorp Vault settings, used by pools configured with `server_auth = "vault"`. +/// HashiCorp Vault settings, used by pools configured with `server_auth = "vault_dynamic"` +/// or `"vault_static"`. /// /// PgDog logs into Vault using the configured auth method and fetches -/// dynamic database credentials from the per-user `vault_path`. +/// database credentials from the per-user `backend_vault_path`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Vault { diff --git a/pgdog/src/auth/vault.rs b/pgdog/src/auth/vault.rs index 4f796ccff..9c0471218 100644 --- a/pgdog/src/auth/vault.rs +++ b/pgdog/src/auth/vault.rs @@ -61,16 +61,20 @@ struct AuthData { /// /// Unlike dynamic leases, `lease_duration` is always 0; `data.ttl` is the /// seconds remaining until Vault rotates the password. +/// +/// `pub(crate)` because [`crate::backend::auth::vault::static_backend_credentials`] +/// deserializes into it directly to pick up the fixed username as well. #[derive(Deserialize)] -struct StaticSecretResponse { - data: StaticSecretData, +pub(crate) struct StaticSecretResponse { + pub(crate) data: StaticSecretData, } #[derive(Deserialize)] -struct StaticSecretData { - password: String, +pub(crate) struct StaticSecretData { + pub(crate) username: String, + pub(crate) password: String, /// Seconds until Vault rotates the password. - ttl: u64, + pub(crate) ttl: u64, } pub(crate) fn error(message: impl std::fmt::Display) -> Error { @@ -367,7 +371,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/database/static-creds/expired-role")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": { "password": "fresh-pw", "ttl": 3600 } + "data": { "username": "pgdog-static", "password": "fresh-pw", "ttl": 3600 } }))) .mount(&server) .await; @@ -400,7 +404,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/database/static-creds/pgdog-static-role")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": { "password": "rotated-secret", "ttl": 3600 } + "data": { "username": "pgdog-static", "password": "rotated-secret", "ttl": 3600 } }))) .mount(&server) .await; diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index f6d744801..7251eea73 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -1,11 +1,14 @@ -//! HashiCorp Vault dynamic database credentials for backend pools. +//! HashiCorp Vault dynamic and static database credentials for backend pools. //! -//! Pools configured with `server_auth = "vault"` fetch their username and -//! password from a Vault database secrets engine path (e.g. -//! `database/creds/my-role`). Credentials are cached in the global -//! [`TokenCache`](crate::backend::pool::token_cache::TokenCache) and -//! refreshed by the pool monitor after a configured percentage of the -//! lease. +//! Pools configured with `server_auth = "vault_dynamic"` fetch their +//! username and password from a Vault database secrets engine path (e.g. +//! `database/creds/my-role`). Pools configured with `server_auth = +//! "vault_static"` fetch the current password for a Vault static database +//! role instead (e.g. `database/static-creds/my-role`); the username is +//! fixed and Vault manages rotation. Either way, credentials are cached in +//! the global [`TokenCache`](crate::backend::pool::token_cache::TokenCache) +//! and refreshed by the pool monitor after a configured percentage of the +//! lease/TTL. //! //! The Vault login/token cache itself lives in //! [`crate::auth::vault`], shared with static role client-auth @@ -16,7 +19,7 @@ use std::time::{Duration, SystemTime}; use serde::Deserialize; use tracing::info; -use crate::auth::vault::{VAULT_TOKEN, client, error, vault_token}; +use crate::auth::vault::{StaticSecretResponse, VAULT_TOKEN, client, error, vault_token}; use crate::backend::pool::token_cache::{Credentials, FetchedCredentials}; use crate::backend::{Error, pool::Address}; use crate::config::config; @@ -121,6 +124,95 @@ pub(crate) async fn credentials(addr: Address) -> Result Result { + let vault = config() + .config + .vault + .clone() + .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; + + let path = addr.vault_path.as_deref().ok_or_else(|| { + error(format!( + r#""vault_path" is not configured for {}@{}:{}"#, + addr.user, addr.host, addr.port + )) + })?; + + let token = vault_token(&vault).await?; + let url = format!( + "{}/v1/{}", + vault.url.trim_end_matches('/'), + path.trim_start_matches('/') + ); + + let response = client(&vault)? + .get(&url) + .header("X-Vault-Token", token) + .send() + .await + .map_err(|err| { + error(format!( + "Vault static backend credentials request to \"{}\" failed: {}", + url, err + )) + })?; + + let status = response.status(); + + if status == reqwest::StatusCode::FORBIDDEN { + *VAULT_TOKEN.lock() = None; + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(error(format!( + "Vault static backend credentials read at \"{}\" returned {}: {}", + url, status, body + ))); + } + + let secret: StaticSecretResponse = response.json().await.map_err(|err| { + error(format!( + "invalid Vault static backend credentials response: {}", + err + )) + })?; + + let ttl = Duration::from_secs(secret.data.ttl); + let now = SystemTime::now(); + + let refresh_percent = addr + .vault_refresh_percent + .unwrap_or(DEFAULT_REFRESH_PERCENT) + .clamp(1, 80); + let refresh_at = now + ttl.mul_f64(refresh_percent as f64 / 100.0); + + info!( + user = %addr.user, + vault_user = %secret.data.username, + ttl_secs = secret.data.ttl, + "fetched Vault static backend credentials" + ); + + Ok(FetchedCredentials { + credentials: Credentials { + username: Some(secret.data.username), + secret: secret.data.password, + }, + expires_at: now + ttl, + refresh_at: Some(refresh_at), + }) +} + #[cfg(test)] mod tests { use pgdog_config::Role; @@ -294,4 +386,133 @@ mod tests { assert!(msg.contains("500"), "expected 500 in: {msg}"); assert!(msg.contains("internal error"), "expected body in: {msg}"); } + + // ── static_backend_credentials(): config-level error cases ──────────────── + + #[tokio::test] + async fn test_static_backend_credentials_no_vault_config() { + crate::config::set(ConfigAndUsers::default()).unwrap(); + + let err = static_backend_credentials(make_addr(Some("database/static-creds/role"))) + .await + .unwrap_err(); + assert!( + err.to_string().contains("[vault] section is missing"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_static_backend_credentials_no_vault_path() { + set_vault_config(approle_vault("http://127.0.0.1:8200")); + + let err = static_backend_credentials(make_addr(None)) + .await + .unwrap_err(); + assert!( + err.to_string().contains("vault_path"), + "unexpected error: {err}" + ); + } + + // ── static_backend_credentials(): HTTP responses ─────────────────────────── + + #[tokio::test] + async fn test_static_backend_credentials_success() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.tok", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/pgdog-static-role")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "username": "pgdog_static", "password": "vault-rotated-pass", "ttl": 600 } + }))) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + set_vault_config(approle_vault(&server.uri())); + + let fetched = + static_backend_credentials(make_addr(Some("database/static-creds/pgdog-static-role"))) + .await + .unwrap(); + assert_eq!( + fetched.credentials.username.as_deref(), + Some("pgdog_static") + ); + assert_eq!(fetched.credentials.secret, "vault-rotated-pass"); + assert!(fetched.expires_at > SystemTime::now()); + assert!(fetched.refresh_at.is_some()); + } + + #[tokio::test] + async fn test_static_backend_credentials_forbidden_clears_token_cache() { + setup(); + let server = MockServer::start().await; + + *VAULT_TOKEN.lock() = Some(VaultToken { + token: "s.stale".into(), + expires_at: SystemTime::now() + Duration::from_secs(3600), + }); + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/pgdog-static-role")) + .respond_with(ResponseTemplate::new(403).set_body_string("token revoked")) + .mount(&server) + .await; + + set_vault_config(approle_vault(&server.uri())); + + let err = + static_backend_credentials(make_addr(Some("database/static-creds/pgdog-static-role"))) + .await + .unwrap_err(); + assert!(err.to_string().contains("403"), "unexpected error: {err}"); + assert!( + VAULT_TOKEN.lock().is_none(), + "token cache should be cleared after 403" + ); + } + + #[tokio::test] + async fn test_static_backend_credentials_error_response() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.tok2", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/pgdog-static-role")) + .respond_with(ResponseTemplate::new(500).set_body_string("internal error")) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + set_vault_config(approle_vault(&server.uri())); + + let err = + static_backend_credentials(make_addr(Some("database/static-creds/pgdog-static-role"))) + .await + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("500"), "expected 500 in: {msg}"); + assert!(msg.contains("internal error"), "expected body in: {msg}"); + } } diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index f28d51419..7bc83a3cb 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -95,7 +95,7 @@ impl Address { }, server_auth, server_iam_region: user.server_iam_region.clone(), - vault_path: user.vault_path.clone(), + vault_path: user.backend_vault_path.clone(), vault_refresh_percent: user.vault_refresh_percent, database_number, configured_role: database.role, @@ -137,7 +137,7 @@ impl Address { vec![Password::new(&token, PasswordSource::AzureIdentity)] } - ServerAuth::Vault => { + ServerAuth::VaultDynamic => { let credentials = TokenCache::global() .credentials_or_fetch(self, vault::credentials) .await?; @@ -146,6 +146,16 @@ impl Address { } vec![Password::new(&credentials.secret, PasswordSource::Vault)] } + + ServerAuth::VaultStatic => { + let credentials = TokenCache::global() + .credentials_or_fetch(self, vault::static_backend_credentials) + .await?; + if let Some(username) = credentials.username { + user = username; + } + vec![Password::new(&credentials.secret, PasswordSource::Vault)] + } }; // Give the valid password first. @@ -491,7 +501,7 @@ mod test { host: "auth-secrets-vault.internal".into(), port: 15435, user: "configured_user".into(), - server_auth: ServerAuth::Vault, + server_auth: ServerAuth::VaultDynamic, vault_path: Some("database/creds/pgdog".into()), ..Default::default() }; @@ -537,8 +547,8 @@ mod test { let user = User { name: "pgdog".into(), - server_auth: ServerAuth::Vault, - vault_path: Some("database/creds/pgdog".into()), + server_auth: ServerAuth::VaultDynamic, + backend_vault_path: Some("database/creds/pgdog".into()), vault_refresh_percent: Some(50), password: Some("ignored".into()), database: "pgdog".into(), @@ -550,11 +560,78 @@ mod test { address.passwords.is_empty(), "Vault addresses must not carry static passwords" ); - assert_eq!(address.server_auth, ServerAuth::Vault); + assert_eq!(address.server_auth, ServerAuth::VaultDynamic); assert_eq!(address.vault_path.as_deref(), Some("database/creds/pgdog")); assert_eq!(address.vault_refresh_percent, Some(50)); } + #[tokio::test] + async fn test_auth_credentials_vault_static_serves_username_and_password_from_cache() { + use crate::backend::pool::token_cache::{Credentials, FetchedCredentials}; + + let addr = Address { + host: "auth-secrets-vault-static.internal".into(), + port: 15436, + user: "configured_user".into(), + server_auth: ServerAuth::VaultStatic, + vault_path: Some("database/static-creds/pgdog-static".into()), + ..Default::default() + }; + + let now = SystemTime::now(); + TokenCache::global().set_credentials( + &addr, + FetchedCredentials { + credentials: Credentials { + username: Some("pgdog_static".into()), + secret: "vault-rotated-pass".into(), + }, + expires_at: now + Duration::from_secs(3600), + refresh_at: Some(now + Duration::from_secs(2880)), + }, + ); + + let (user, secrets) = addr.auth_credentials().await.unwrap(); + + TokenCache::global().evict(&addr); + + // Static role: Vault's username overrides the configured one. + assert_eq!(user, "pgdog_static"); + assert_eq!(secrets.first().unwrap(), "vault-rotated-pass"); + } + + #[test] + fn test_vault_static_fields_from_config() { + let database = Database { + name: "pgdog".into(), + host: "127.0.0.1".into(), + port: 6432, + ..Default::default() + }; + + let user = User { + name: "pgdog_static".into(), + server_auth: ServerAuth::VaultStatic, + backend_vault_path: Some("database/static-creds/pgdog-static".into()), + vault_refresh_percent: Some(70), + password: Some("ignored".into()), + database: "pgdog".into(), + ..Default::default() + }; + + let address = Address::new(&database, &user, 0); + assert!( + address.passwords.is_empty(), + "VaultStatic addresses must not carry static passwords" + ); + assert_eq!(address.server_auth, ServerAuth::VaultStatic); + assert_eq!( + address.vault_path.as_deref(), + Some("database/static-creds/pgdog-static") + ); + assert_eq!(address.vault_refresh_percent, Some(70)); + } + #[tokio::test] async fn test_auth_secret_stale_token_still_returned() { // A stale token (past its expiry) is still handed to the server. diff --git a/pgdog/src/backend/pool/monitor.rs b/pgdog/src/backend/pool/monitor.rs index f9085ace7..134746a5a 100644 --- a/pgdog/src/backend/pool/monitor.rs +++ b/pgdog/src/backend/pool/monitor.rs @@ -204,12 +204,20 @@ impl Monitor { }, ) } - ServerAuth::Vault => { + ServerAuth::VaultDynamic => { vault::credentials(addr.clone()).await.map(|credentials| { TokenCache::global().set_credentials(&addr, credentials); pool.lock().bump_credentials_generation(); }) } + ServerAuth::VaultStatic => { + vault::static_backend_credentials(addr.clone()) + .await + .map(|credentials| { + TokenCache::global().set_credentials(&addr, credentials); + pool.lock().bump_credentials_generation(); + }) + } // Guard in spawn() ensures we only reach here for // external identity pools. _ => break, From f2b7eeccc03262f722cdd2550540dec8426951ff Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Wed, 1 Jul 2026 15:25:22 +0200 Subject: [PATCH 4/9] docs --- README.md | 31 +++++++++++++++++++++++++++++-- example.pgdog.toml | 3 ++- example.users.toml | 23 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 80dbebfdf..68dcd35e5 100644 --- a/README.md +++ b/README.md @@ -198,12 +198,13 @@ role = "auto" 📘 **[Authentication](https://docs.pgdog.dev/features/authentication/)** -PgDog supports four authentication methods: +PgDog supports five authentication methods: 1. Password-based 2. AWS RDS IAM 3. Azure Workload Identity 4. HashiCorp Vault dynamic credentials +5. HashiCorp Vault static role credentials #### Password-based authentication @@ -253,7 +254,7 @@ When any user has `server_auth = "azure_workload_identity"`, the following setti - `tls_verify` must **not** be `"disabled"`. - `passthrough_auth` must be `"disabled"`. -#### HashiCorp Vault authentication +#### HashiCorp Vault dynamic role authentication PgDog can fetch dynamic database credentials (username and password) from HashiCorp Vault's database secrets engine, while keeping client-to-PgDog authentication unchanged. Credentials are cached and rotated automatically after a configured percentage of the Vault lease has elapsed. @@ -288,6 +289,32 @@ When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the follo - `tls_verify` must **not** be `"disabled"`. - `passthrough_auth` must be `"disabled"`. +#### HashiCorp Vault static role authentication + +Unlike dynamic credentials, a Vault static database role has a fixed username; only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role, and they can be combined on the same user: + +- `client_vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. +- `server_auth = "vault_static"` with `backend_vault_path`: use the role's Vault-managed username and password for PgDog-to-PostgreSQL connections, refreshed like dynamic credentials. + +**Example** + +In `users.toml`: + +```toml +[[users]] +name = "alice" +database = "pgdog" +client_vault_path = "database/static-creds/pgdog-static-role" +server_auth = "vault_static" +backend_vault_path = "database/static-creds/pgdog-static-role" +# Refresh the backend password after 80% of the rotation TTL (default). +# vault_refresh_percent = 80 +``` + +In `pgdog.toml`, the same `[vault]` section used for dynamic credentials applies. + +Both settings are optional and independent: set only `client_vault_path` to verify client passwords while keeping any other backend authentication method, or only `server_auth = "vault_static"` to use the static role for backend connections while clients authenticate with a regular password. + ### Sharding 📘 **[Sharding](https://docs.pgdog.dev/features/sharding/)** diff --git a/example.pgdog.toml b/example.pgdog.toml index 96b2bb117..2c2c5311b 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -473,7 +473,8 @@ fingerprint = "2d9944fc9caeaadd" # [3285733254894627549] # exposure = 0.5 # Optional: overrides general.mirror_exposure # HashiCorp Vault settings, required when any user in users.toml -# sets `server_auth = "vault_dynamic"`. +# sets `server_auth = "vault_dynamic"` or `"vault_static"`, or configures +# `client_vault_path` for client-side static role password verification. # # [vault] # url = "https://vault.internal:8200" diff --git a/example.users.toml b/example.users.toml index 37009ff20..fe1353d79 100644 --- a/example.users.toml +++ b/example.users.toml @@ -30,3 +30,26 @@ password = "pgdog" # server_auth = "vault_dynamic" # backend_vault_path = "database/creds/pgdog" # vault_refresh_percent = 80 # optional; default 80 + +# Example: client authentication against a HashiCorp Vault static database role. +# Requires the [vault] section in pgdog.toml. PgDog verifies the password the +# client sends against Vault's current password for the static role, instead +# of a statically configured password. Independent of `server_auth` below; +# combine it with any backend authentication method. +# client_vault_path = "database/static-creds/pgdog-static-role" + +# Example: backend authentication with HashiCorp Vault static credentials. +# Requires the [vault] section in pgdog.toml. Unlike vault_dynamic, the +# username is fixed; only the password rotates. PgDog refreshes it after +# `vault_refresh_percent` of the role's rotation TTL has elapsed. +# server_auth = "vault_static" +# backend_vault_path = "database/static-creds/pgdog-static-role" + +# Example: client and backend both authenticated against the same Vault +# static role, so PgDog and Postgres always agree on the current password. +# [[users]] +# name = "pgdog_static" +# database = "pgdog" +# client_vault_path = "database/static-creds/pgdog-static-role" +# server_auth = "vault_static" +# backend_vault_path = "database/static-creds/pgdog-static-role" From f8583c36848b3f119ad5fb7b9dba19b2a8b0ad52 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Wed, 1 Jul 2026 19:18:55 +0200 Subject: [PATCH 5/9] fixes --- README.md | 15 +-- example.users.toml | 26 ++-- pgdog/src/auth/vault.rs | 108 +++++++-------- pgdog/src/backend/auth/vault.rs | 209 +++++++++--------------------- pgdog/src/backend/pool/address.rs | 32 ++--- pgdog/src/backend/pool/monitor.rs | 15 +-- 6 files changed, 141 insertions(+), 264 deletions(-) diff --git a/README.md b/README.md index 68dcd35e5..1813d34c1 100644 --- a/README.md +++ b/README.md @@ -291,29 +291,28 @@ When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the follo #### HashiCorp Vault static role authentication -Unlike dynamic credentials, a Vault static database role has a fixed username; only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role, and they can be combined on the same user: +Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role — they don't need to point at the same role, and each has its own username setting: - `client_vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. -- `server_auth = "vault_static"` with `backend_vault_path`: use the role's Vault-managed username and password for PgDog-to-PostgreSQL connections, refreshed like dynamic credentials. +- `server_auth = "vault_static"` with `backend_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` (or `name`, if `server_user` isn't set), and only checks the fetched password on demand (no proactive refresh). **Example** -In `users.toml`: +In `users.toml`, for a client authenticating as `alice` (verified against a static role registered under that same name) while PgDog connects to Postgres as `pgdog_service` (a separate static role): ```toml [[users]] name = "alice" database = "pgdog" -client_vault_path = "database/static-creds/pgdog-static-role" +client_vault_path = "database/static-creds/alice" +server_user = "pgdog_service" server_auth = "vault_static" -backend_vault_path = "database/static-creds/pgdog-static-role" -# Refresh the backend password after 80% of the rotation TTL (default). -# vault_refresh_percent = 80 +backend_vault_path = "database/static-creds/pgdog-service" ``` In `pgdog.toml`, the same `[vault]` section used for dynamic credentials applies. -Both settings are optional and independent: set only `client_vault_path` to verify client passwords while keeping any other backend authentication method, or only `server_auth = "vault_static"` to use the static role for backend connections while clients authenticate with a regular password. +Both settings are optional and independent: set only `client_vault_path` to verify client passwords while keeping any other backend authentication method, or only `server_auth = "vault_static"` to use a static role for backend connections while clients authenticate with a regular password. ### Sharding diff --git a/example.users.toml b/example.users.toml index fe1353d79..eb2d5d274 100644 --- a/example.users.toml +++ b/example.users.toml @@ -33,23 +33,13 @@ password = "pgdog" # Example: client authentication against a HashiCorp Vault static database role. # Requires the [vault] section in pgdog.toml. PgDog verifies the password the -# client sends against Vault's current password for the static role, instead -# of a statically configured password. Independent of `server_auth` below; -# combine it with any backend authentication method. -# client_vault_path = "database/static-creds/pgdog-static-role" +# client sends against Vault's current password for the static role at `client_vault_path`, +# instead of a statically configured password. +# Independent of `server_auth` below; combine it with any backend authentication method. +# client_vault_path = "database/static-creds/alice" -# Example: backend authentication with HashiCorp Vault static credentials. -# Requires the [vault] section in pgdog.toml. Unlike vault_dynamic, the -# username is fixed; only the password rotates. PgDog refreshes it after -# `vault_refresh_percent` of the role's rotation TTL has elapsed. +# Example: backend authentication with a HashiCorp Vault static role. +# Requires the [vault] section in pgdog.toml. +# server_user = "pgdog_service" # server_auth = "vault_static" -# backend_vault_path = "database/static-creds/pgdog-static-role" - -# Example: client and backend both authenticated against the same Vault -# static role, so PgDog and Postgres always agree on the current password. -# [[users]] -# name = "pgdog_static" -# database = "pgdog" -# client_vault_path = "database/static-creds/pgdog-static-role" -# server_auth = "vault_static" -# backend_vault_path = "database/static-creds/pgdog-static-role" +# backend_vault_path = "database/static-creds/pgdog-service" diff --git a/pgdog/src/auth/vault.rs b/pgdog/src/auth/vault.rs index 9c0471218..09206d755 100644 --- a/pgdog/src/auth/vault.rs +++ b/pgdog/src/auth/vault.rs @@ -4,9 +4,7 @@ //! consumer in the codebase: //! //! - **Client authentication** — users configured with `client_vault_path` -//! (e.g. `database/static-creds/my-role`) have their client-supplied -//! password verified against the current password held by Vault for that -//! static role. Results are cached for the role's rotation TTL. +//! retrieve their password from Vault and results are cached for the role's rotation TTL. //! - **Backend pools** (`src/backend/auth/vault.rs`) reuse the login/token //! cache here to fetch dynamic database credentials. @@ -16,6 +14,7 @@ use std::time::{Duration, SystemTime}; use once_cell::sync::Lazy; use parking_lot::Mutex; use serde::Deserialize; +use serde::de::DeserializeOwned; use serde_json::json; use tracing::debug; @@ -23,29 +22,15 @@ use crate::backend::Error; use crate::config::config; use pgdog_config::vault::{Vault, VaultAuthMethod}; +/// Cached Vault client token, shared by all pools. +pub(crate) static VAULT_TOKEN: Lazy>> = Lazy::new(|| Mutex::new(None)); + #[derive(Clone, Debug)] pub(crate) struct VaultToken { pub(crate) token: String, pub(crate) expires_at: SystemTime, } -/// Cached Vault client token, shared by all pools. -pub(crate) static VAULT_TOKEN: Lazy>> = Lazy::new(|| Mutex::new(None)); - -// ── Static role client-auth cache ──────────────────────────────────────────── - -/// How early to evict a static role password before its rotation TTL expires, -/// to reduce the chance of serving a stale password right at the rotation boundary. -const STATIC_CACHE_BUFFER: Duration = Duration::from_secs(10); - -struct CachedStaticPassword { - password: String, - expires_at: SystemTime, -} - -static CLIENT_PASSWORD_CACHE: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - #[derive(Deserialize)] struct AuthResponse { auth: AuthData, @@ -57,13 +42,21 @@ struct AuthData { lease_duration: u64, } +/// Static role client-auth cache +struct CachedStaticPassword { + password: String, + expires_at: SystemTime, +} + +static CLIENT_PASSWORD_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + /// Response from a Vault static database role (`database/static-creds/`). /// /// Unlike dynamic leases, `lease_duration` is always 0; `data.ttl` is the /// seconds remaining until Vault rotates the password. /// -/// `pub(crate)` because [`crate::backend::auth::vault::static_backend_credentials`] -/// deserializes into it directly to pick up the fixed username as well. +/// `pub(crate)` because [`crate::backend::auth::vault::static_backend_credentials`] deserializes into it directly. #[derive(Deserialize)] pub(crate) struct StaticSecretResponse { pub(crate) data: StaticSecretData, @@ -71,7 +64,6 @@ pub(crate) struct StaticSecretResponse { #[derive(Deserialize)] pub(crate) struct StaticSecretData { - pub(crate) username: String, pub(crate) password: String, /// Seconds until Vault rotates the password. pub(crate) ttl: u64, @@ -197,43 +189,27 @@ pub(crate) async fn vault_token(vault: &Vault) -> Result { Ok(secret) } -/// Return the current password for a Vault static database role, using a -/// per-path cache keyed by `vault_path`. +/// Fetch and parse a JSON secret from `path` in Vault. /// -/// The cached value is evicted `STATIC_CACHE_BUFFER` before the role's -/// rotation TTL expires so the next connection after a rotation picks up -/// the new password. -pub(crate) async fn static_client_password(vault_path: &str) -> Result { - if let Some(cached) = CLIENT_PASSWORD_CACHE.lock().get(vault_path) { - if SystemTime::now() < cached.expires_at { - return Ok(cached.password.clone()); - } - } - - let vault = config() - .config - .vault - .clone() - .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; - - let token = vault_token(&vault).await?; +/// Handles token acquisition, a `403` response by dropping the cached +/// client token (so the next attempt logs in again), and non-success responses. `context` +pub(crate) async fn fetch_secret( + vault: &Vault, + path: &str, +) -> Result { + let token = vault_token(vault).await?; let url = format!( "{}/v1/{}", vault.url.trim_end_matches('/'), - vault_path.trim_start_matches('/') + path.trim_start_matches('/') ); - let response = client(&vault)? + let response = client(vault)? .get(&url) .header("X-Vault-Token", token) .send() .await - .map_err(|err| { - error(format!( - "Vault static credentials request to \"{}\" failed: {}", - url, err - )) - })?; + .map_err(|err| error(format!("request to \"{}\" failed: {}", url, err)))?; let status = response.status(); @@ -244,20 +220,36 @@ pub(crate) async fn static_client_password(vault_path: &str) -> Result Result { + if let Some(cached) = CLIENT_PASSWORD_CACHE.lock().get(vault_path) + && SystemTime::now() < cached.expires_at + { + return Ok(cached.password.clone()); + } + + let vault = config() + .config + .vault + .clone() + .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; + + let secret: StaticSecretResponse = fetch_secret(&vault, vault_path).await?; let ttl = Duration::from_secs(secret.data.ttl); - let expires_at = SystemTime::now() + ttl.saturating_sub(STATIC_CACHE_BUFFER); + let expires_at = SystemTime::now() + ttl; debug!( vault_path, diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 7251eea73..f57c5627e 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -1,25 +1,27 @@ //! HashiCorp Vault dynamic and static database credentials for backend pools. //! //! Pools configured with `server_auth = "vault_dynamic"` fetch their -//! username and password from a Vault database secrets engine path (e.g. -//! `database/creds/my-role`). Pools configured with `server_auth = -//! "vault_static"` fetch the current password for a Vault static database -//! role instead (e.g. `database/static-creds/my-role`); the username is -//! fixed and Vault manages rotation. Either way, credentials are cached in -//! the global [`TokenCache`](crate::backend::pool::token_cache::TokenCache) -//! and refreshed by the pool monitor after a configured percentage of the -//! lease/TTL. +//! username and password from a Vault database secrets engine path. +//! Vault generates the username, so credentials are cached in the global +//! [`TokenCache`](crate::backend::pool::token_cache::TokenCache) via +//! [`TokenCache::credentials_or_fetch`] and proactively refreshed by the pool +//! monitor after a configured percentage of the lease. +//! +//! Pools configured with `server_auth = "vault_static"` fetch the current +//! password for a Vault static database role instead. +//! The username is operator-supplied (like RDS IAM and Azure Workload Identity) so +//! credentials are cached via [`TokenCache::get_or_fetch`] and refreshed +//! only when the password is expired. //! //! The Vault login/token cache itself lives in -//! [`crate::auth::vault`], shared with static role client-auth -//! verification. +//! [`crate::auth::vault`], shared with static role client-auth verification. use std::time::{Duration, SystemTime}; use serde::Deserialize; use tracing::info; -use crate::auth::vault::{StaticSecretResponse, VAULT_TOKEN, client, error, vault_token}; +use crate::auth::vault::{StaticSecretResponse, error, fetch_secret}; use crate::backend::pool::token_cache::{Credentials, FetchedCredentials}; use crate::backend::{Error, pool::Address}; use crate::config::config; @@ -37,14 +39,8 @@ struct SecretData { password: String, } -/// Fetch fresh dynamic database credentials for `addr` from Vault. -/// -/// This is the raw fetcher passed to [`TokenCache::credentials_or_fetch`] -/// and called by the monitor's refresh loop. Callers should never invoke -/// it directly — go through -/// [`TokenCache::global`](crate::backend::pool::token_cache::TokenCache::global) -/// instead. -pub(crate) async fn credentials(addr: Address) -> Result { +/// Vault path and config lookup shared by every backend credential fetcher. +fn vault_and_path(addr: &Address) -> Result<(pgdog_config::vault::Vault, &str), Error> { let vault = config() .config .vault @@ -58,45 +54,33 @@ pub(crate) async fn credentials(addr: Address) -> Result, +) -> SystemTime { + let refresh_percent = refresh_percent + .unwrap_or(DEFAULT_REFRESH_PERCENT) + .clamp(1, 80); + now + lifetime.mul_f64(refresh_percent as f64 / 100.0) +} - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(error(format!( - "Vault credentials read at \"{}\" returned {}: {}", - url, status, body - ))); - } +/// Fetch fresh dynamic database credentials for `addr` from Vault. +/// +/// This is the raw fetcher passed to [`TokenCache::credentials_or_fetch`] +/// and called by the monitor's refresh loop. Callers should never invoke +/// it directly — go through +/// [`TokenCache::global`](crate::backend::pool::token_cache::TokenCache::global) +/// instead. +pub(crate) async fn credentials(addr: Address) -> Result { + let (vault, path) = vault_and_path(&addr)?; - let secret: SecretResponse = response - .json() - .await - .map_err(|err| error(format!("invalid Vault credentials response: {}", err)))?; + let secret: SecretResponse = fetch_secret(&vault, path).await?; let lease = Duration::from_secs(secret.lease_duration); @@ -107,12 +91,8 @@ pub(crate) async fn credentials(addr: Address) -> Result Result Result { - let vault = config() - .config - .vault - .clone() - .ok_or_else(|| error("[vault] section is missing from pgdog.toml"))?; - - let path = addr.vault_path.as_deref().ok_or_else(|| { - error(format!( - r#""vault_path" is not configured for {}@{}:{}"#, - addr.user, addr.host, addr.port - )) - })?; - - let token = vault_token(&vault).await?; - let url = format!( - "{}/v1/{}", - vault.url.trim_end_matches('/'), - path.trim_start_matches('/') - ); - - let response = client(&vault)? - .get(&url) - .header("X-Vault-Token", token) - .send() - .await - .map_err(|err| { - error(format!( - "Vault static backend credentials request to \"{}\" failed: {}", - url, err - )) - })?; - - let status = response.status(); - - if status == reqwest::StatusCode::FORBIDDEN { - *VAULT_TOKEN.lock() = None; - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(error(format!( - "Vault static backend credentials read at \"{}\" returned {}: {}", - url, status, body - ))); - } - - let secret: StaticSecretResponse = response.json().await.map_err(|err| { - error(format!( - "invalid Vault static backend credentials response: {}", - err - )) - })?; - - let ttl = Duration::from_secs(secret.data.ttl); - let now = SystemTime::now(); +/// Unlike dynamic credentials, the username it's fixed and supplied in the config. +/// This is the raw fetcher passed to [`TokenCache::get_or_fetch`] and called +/// by the monitor's refresh loop. Callers should never invoke it directly — +/// go through +/// [`TokenCache::global`](crate::backend::pool::token_cache::TokenCache::global) +/// instead. +pub(crate) async fn static_backend_credentials( + addr: Address, +) -> Result<(String, SystemTime), Error> { + let (vault, path) = vault_and_path(&addr)?; - let refresh_percent = addr - .vault_refresh_percent - .unwrap_or(DEFAULT_REFRESH_PERCENT) - .clamp(1, 80); - let refresh_at = now + ttl.mul_f64(refresh_percent as f64 / 100.0); + let secret: StaticSecretResponse = fetch_secret(&vault, path).await?; info!( user = %addr.user, - vault_user = %secret.data.username, ttl_secs = secret.data.ttl, "fetched Vault static backend credentials" ); - Ok(FetchedCredentials { - credentials: Credentials { - username: Some(secret.data.username), - secret: secret.data.password, - }, - expires_at: now + ttl, - refresh_at: Some(refresh_at), - }) + let expires_at = SystemTime::now() + Duration::from_secs(secret.data.ttl); + + Ok((secret.data.password, expires_at)) } #[cfg(test)] @@ -222,7 +140,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; - use crate::auth::vault::VaultToken; + use crate::auth::vault::{VAULT_TOKEN, VaultToken}; use crate::config::ConfigAndUsers; fn setup() { @@ -433,7 +351,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/database/static-creds/pgdog-static-role")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": { "username": "pgdog_static", "password": "vault-rotated-pass", "ttl": 600 } + "data": { "username": "testuser", "password": "vault-rotated-pass", "ttl": 600 } }))) .mount(&server) .await; @@ -442,17 +360,12 @@ mod tests { *VAULT_TOKEN.lock() = None; set_vault_config(approle_vault(&server.uri())); - let fetched = + let (password, expires_at) = static_backend_credentials(make_addr(Some("database/static-creds/pgdog-static-role"))) .await .unwrap(); - assert_eq!( - fetched.credentials.username.as_deref(), - Some("pgdog_static") - ); - assert_eq!(fetched.credentials.secret, "vault-rotated-pass"); - assert!(fetched.expires_at > SystemTime::now()); - assert!(fetched.refresh_at.is_some()); + assert_eq!(password, "vault-rotated-pass"); + assert!(expires_at > SystemTime::now()); } #[tokio::test] diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 7bc83a3cb..0d233051b 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -114,7 +114,7 @@ impl Address { /// Get the username and passwords to log into the server with. /// - /// The username is the configured one, except for Vault-backed pools + /// The username is the configured one, except for `vault_dynamic` pools /// where the database secrets engine generates it together with the /// password. pub async fn auth_credentials(&self) -> Result<(String, Vec), Error> { @@ -148,13 +148,10 @@ impl Address { } ServerAuth::VaultStatic => { - let credentials = TokenCache::global() - .credentials_or_fetch(self, vault::static_backend_credentials) + let password = TokenCache::global() + .get_or_fetch(self, vault::static_backend_credentials) .await?; - if let Some(username) = credentials.username { - user = username; - } - vec![Password::new(&credentials.secret, PasswordSource::Vault)] + vec![Password::new(&password, PasswordSource::Vault)] } }; @@ -566,36 +563,23 @@ mod test { } #[tokio::test] - async fn test_auth_credentials_vault_static_serves_username_and_password_from_cache() { - use crate::backend::pool::token_cache::{Credentials, FetchedCredentials}; - + async fn test_auth_credentials_vault_static_serves_password_from_cache() { let addr = Address { host: "auth-secrets-vault-static.internal".into(), port: 15436, - user: "configured_user".into(), + user: "pgdog_static".into(), server_auth: ServerAuth::VaultStatic, vault_path: Some("database/static-creds/pgdog-static".into()), ..Default::default() }; - let now = SystemTime::now(); - TokenCache::global().set_credentials( - &addr, - FetchedCredentials { - credentials: Credentials { - username: Some("pgdog_static".into()), - secret: "vault-rotated-pass".into(), - }, - expires_at: now + Duration::from_secs(3600), - refresh_at: Some(now + Duration::from_secs(2880)), - }, - ); + let expiry = SystemTime::now() + Duration::from_secs(3600); + TokenCache::global().set(&addr, "vault-rotated-pass".into(), expiry); let (user, secrets) = addr.auth_credentials().await.unwrap(); TokenCache::global().evict(&addr); - // Static role: Vault's username overrides the configured one. assert_eq!(user, "pgdog_static"); assert_eq!(secrets.first().unwrap(), "vault-rotated-pass"); } diff --git a/pgdog/src/backend/pool/monitor.rs b/pgdog/src/backend/pool/monitor.rs index 134746a5a..41812944f 100644 --- a/pgdog/src/backend/pool/monitor.rs +++ b/pgdog/src/backend/pool/monitor.rs @@ -204,20 +204,19 @@ impl Monitor { }, ) } + ServerAuth::VaultStatic => { + vault::static_backend_credentials(addr.clone()).await.map( + |(token, expires_at)| { + TokenCache::global().set(&addr, token, expires_at) + }, + ) + } ServerAuth::VaultDynamic => { vault::credentials(addr.clone()).await.map(|credentials| { TokenCache::global().set_credentials(&addr, credentials); pool.lock().bump_credentials_generation(); }) } - ServerAuth::VaultStatic => { - vault::static_backend_credentials(addr.clone()) - .await - .map(|credentials| { - TokenCache::global().set_credentials(&addr, credentials); - pool.lock().bump_credentials_generation(); - }) - } // Guard in spawn() ensures we only reach here for // external identity pools. _ => break, From 6900dc6ba8ee1f6243da0f6c6e7c383478074581 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Wed, 1 Jul 2026 21:14:31 +0200 Subject: [PATCH 6/9] fix refresh for static backend --- README.md | 2 +- integration/vault/setup.sh | 2 +- pgdog/src/backend/auth/vault.rs | 40 +++++++++++++++------ pgdog/src/backend/pool/address.rs | 2 +- pgdog/src/backend/pool/monitor.rs | 6 ++-- pgdog/src/backend/pool/token_cache.rs | 50 +++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1813d34c1..033fb947e 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the follo Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role — they don't need to point at the same role, and each has its own username setting: - `client_vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. -- `server_auth = "vault_static"` with `backend_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` (or `name`, if `server_user` isn't set), and only checks the fetched password on demand (no proactive refresh). +- `server_auth = "vault_static"` with `backend_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` or `name`, if `server_user` isn't set. **Example** diff --git a/integration/vault/setup.sh b/integration/vault/setup.sh index 4c2022341..6f5a1eb58 100755 --- a/integration/vault/setup.sh +++ b/integration/vault/setup.sh @@ -44,7 +44,7 @@ $VAULT write database/static-roles/pgdog-static-role \ db_name=pgdog \ rotation_statements="ALTER USER \"{{name}}\" WITH PASSWORD '{{password}}';" \ username="pgdog_static" \ - rotation_period=3600 + rotation_period=300 echo "Configuring AppRole auth..." $VAULT auth enable approle 2>/dev/null || true diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index f57c5627e..3fdea6085 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -9,9 +9,12 @@ //! //! Pools configured with `server_auth = "vault_static"` fetch the current //! password for a Vault static database role instead. -//! The username is operator-supplied (like RDS IAM and Azure Workload Identity) so -//! credentials are cached via [`TokenCache::get_or_fetch`] and refreshed -//! only when the password is expired. +//! The username is operator-supplied (like RDS IAM and Azure Workload +//! Identity) so credentials are cached via +//! [`TokenCache::get_or_fetch_with_refresh`]. Unlike RDS/Azure tokens, Vault +//! only issues a new static-role password once the old one's TTL actually +//! expires. That's why [`static_backend_credentials`] schedules its refresh +//! at expiry rather than ahead of it. //! //! The Vault login/token cache itself lives in //! [`crate::auth::vault`], shared with static role client-auth verification. @@ -104,18 +107,30 @@ pub(crate) async fn credentials(addr: Address) -> Result Result<(String, SystemTime), Error> { +) -> Result<(String, SystemTime, SystemTime), Error> { let (vault, path) = vault_and_path(&addr)?; let secret: StaticSecretResponse = fetch_secret(&vault, path).await?; @@ -126,9 +141,11 @@ pub(crate) async fn static_backend_credentials( "fetched Vault static backend credentials" ); - let expires_at = SystemTime::now() + Duration::from_secs(secret.data.ttl); + let now = SystemTime::now(); + let expires_at = now + Duration::from_secs(secret.data.ttl); + let refresh_at = expires_at.max(now + STATIC_ROLE_MIN_REFRESH_BACKOFF); - Ok((secret.data.password, expires_at)) + Ok((secret.data.password, expires_at, refresh_at)) } #[cfg(test)] @@ -360,12 +377,13 @@ mod tests { *VAULT_TOKEN.lock() = None; set_vault_config(approle_vault(&server.uri())); - let (password, expires_at) = + let (password, expires_at, refresh_at) = static_backend_credentials(make_addr(Some("database/static-creds/pgdog-static-role"))) .await .unwrap(); assert_eq!(password, "vault-rotated-pass"); assert!(expires_at > SystemTime::now()); + assert!(refresh_at > SystemTime::now()); } #[tokio::test] diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 0d233051b..0b2a999a4 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -149,7 +149,7 @@ impl Address { ServerAuth::VaultStatic => { let password = TokenCache::global() - .get_or_fetch(self, vault::static_backend_credentials) + .get_or_fetch_with_refresh(self, vault::static_backend_credentials) .await?; vec![Password::new(&password, PasswordSource::Vault)] } diff --git a/pgdog/src/backend/pool/monitor.rs b/pgdog/src/backend/pool/monitor.rs index 41812944f..7f001079b 100644 --- a/pgdog/src/backend/pool/monitor.rs +++ b/pgdog/src/backend/pool/monitor.rs @@ -206,8 +206,10 @@ impl Monitor { } ServerAuth::VaultStatic => { vault::static_backend_credentials(addr.clone()).await.map( - |(token, expires_at)| { - TokenCache::global().set(&addr, token, expires_at) + |(token, expires_at, refresh_at)| { + TokenCache::global().set_with_refresh_at( + &addr, token, expires_at, refresh_at, + ) }, ) } diff --git a/pgdog/src/backend/pool/token_cache.rs b/pgdog/src/backend/pool/token_cache.rs index e92f337b5..8ea6b78c5 100644 --- a/pgdog/src/backend/pool/token_cache.rs +++ b/pgdog/src/backend/pool/token_cache.rs @@ -172,6 +172,33 @@ impl TokenCache { .insert(CacheKey::from(addr), CachedToken::new(token, expires_at)); } + /// Store a freshly fetched token for `addr`, along with an explicit + /// refresh instant. + /// + /// Use when a provider's own expiry can't be safely approached with the + /// default [`EXPIRY_BUFFER`] — e.g. a Vault static role, which only + /// issues a new password once its TTL actually expires and echoes back + /// a shrinking TTL if read early, resulting in a tight refresh loop. + pub fn set_with_refresh_at( + &self, + addr: &Address, + token: String, + expires_at: SystemTime, + refresh_at: SystemTime, + ) { + self.inner.lock().insert( + CacheKey::from(addr), + CachedToken { + credentials: Credentials { + username: None, + secret: token, + }, + expires_at, + refresh_at: Some(refresh_at), + }, + ); + } + /// Store freshly fetched credentials for `addr`, including a /// generated username and an explicit refresh instant when present. pub fn set_credentials(&self, addr: &Address, fetched: FetchedCredentials) { @@ -210,6 +237,29 @@ impl TokenCache { Ok(token) } + /// Like [`get_or_fetch`](Self::get_or_fetch), but for providers that + /// compute their own refresh instant instead of relying on + /// [`EXPIRY_BUFFER`] — see [`set_with_refresh_at`](Self::set_with_refresh_at). + pub async fn get_or_fetch_with_refresh( + &self, + addr: &Address, + fetcher: F, + ) -> Result + where + F: Fn(Address) -> Fut + Send + Sync, + Fut: Future>, + { + if let Some(cached) = self.inner.lock().get(&CacheKey::from(addr)).cloned() { + return Ok(cached.credentials.secret); + } + + // Cold miss — block once to prime the cache. + // After this the monitor's refresh loop takes over. + let (token, expires_at, refresh_at) = fetcher(addr.clone()).await?; + self.set_with_refresh_at(addr, token.clone(), expires_at, refresh_at); + Ok(token) + } + /// Like [`get_or_fetch`](Self::get_or_fetch), but for providers that /// generate full credentials (username and password), e.g. Vault's /// database secrets engine. From 1550a53b384bc1d96369363e9198650a8b4f36b0 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Fri, 3 Jul 2026 09:24:57 +0200 Subject: [PATCH 7/9] rename config keys --- .schema/pgdog.schema.json | 2 +- .schema/users.schema.json | 30 +++++++------- README.md | 12 +++--- example.pgdog.toml | 2 +- example.users.toml | 10 ++--- integration/vault/users.toml | 6 +-- pgdog-config/src/users.rs | 69 +++++++------------------------ pgdog-config/src/vault.rs | 2 +- pgdog/src/auth/vault.rs | 2 +- pgdog/src/backend/auth/vault.rs | 2 +- pgdog/src/backend/pool/address.rs | 6 +-- 11 files changed, 52 insertions(+), 91 deletions(-) diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index f9ceea231..6d7b9dbf0 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -2218,7 +2218,7 @@ ] }, "Vault": { - "description": "HashiCorp Vault settings, used by pools configured with `server_auth = \"vault_dynamic\"`\nor `\"vault_static\"`.\n\nPgDog logs into Vault using the configured auth method and fetches\ndatabase credentials from the per-user `backend_vault_path`.", + "description": "HashiCorp Vault settings, used by pools configured with `server_auth = \"vault_dynamic\"`\nor `\"vault_static\"`.\n\nPgDog logs into Vault using the configured auth method and fetches\ndatabase credentials from the per-user `server_vault_path`.", "type": "object", "properties": { "approle_role_id": { diff --git a/.schema/users.schema.json b/.schema/users.schema.json index 1ec28bbae..f0c54fad1 100644 --- a/.schema/users.schema.json +++ b/.schema/users.schema.json @@ -87,7 +87,7 @@ "const": "azure_workload_identity" }, { - "description": "Fetch dynamic credentials from HashiCorp Vault (database secrets engine).\nVault generates a new username and password on each lease.\n\n**Note:** `\"vault\"` is accepted as a deprecated alias for backward compatibility.", + "description": "Fetch dynamic credentials from HashiCorp Vault (database secrets engine).\nVault generates a new username and password on each lease.", "type": "string", "const": "vault_dynamic" }, @@ -107,20 +107,6 @@ "type": "boolean", "default": false }, - "backend_vault_path": { - "description": "Vault path used to fetch backend (server-side) database credentials,\ne.g. `database/creds/my-role` for `server_auth = \"vault_dynamic\"` or\n`database/static-creds/my-role` for `server_auth = \"vault_static\"`.\n\n**Note:** `\"vault_path\"` is accepted as a deprecated alias for backward compatibility.", - "type": [ - "string", - "null" - ] - }, - "client_vault_path": { - "description": "Vault path to a static database role used to verify client passwords,\ne.g. `database/static-creds/my-role`. When set, PgDog fetches the\ncurrent password from Vault and compares it to what the client\nprovides instead of using a statically configured password.", - "type": [ - "string", - "null" - ] - }, "cross_shard_disabled": { "description": "Disable cross-shard queries for this user.", "type": [ @@ -289,6 +275,13 @@ "null" ] }, + "server_vault_path": { + "description": "Vault path used to fetch backend (server-side) database credentials,\ne.g. `database/creds/my-role` for `server_auth = \"vault_dynamic\"` or\n`database/static-creds/my-role` for `server_auth = \"vault_static\"`.", + "type": [ + "string", + "null" + ] + }, "statement_timeout": { "description": "Statement timeout.\n\nSets the `statement_timeout` on all server connections at connection creation. This allows you to set a reasonable default for each user without modifying `postgresql.conf` or using `ALTER USER`.\n\n**Note:** Nothing is preventing the user from manually changing this setting at runtime, e.g., by running `SET statement_timeout TO 0`;\n\nhttps://docs.pgdog.dev/configuration/users.toml/users/#statement_timeout", "type": [ @@ -312,6 +305,13 @@ "null" ] }, + "vault_path": { + "description": "Vault path to a static database role used to verify client passwords,\ne.g. `database/static-creds/my-role`. When set, PgDog fetches the\ncurrent password from Vault and compares it to what the client\nprovides instead of using a statically configured password.", + "type": [ + "string", + "null" + ] + }, "vault_refresh_percent": { "description": "Percentage of the Vault credential lease after which credentials are refreshed.\n\n_Default:_ `80`", "type": [ diff --git a/README.md b/README.md index 033fb947e..af4e6191f 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ name = "alice" database = "pgdog" password = "client-password" server_auth = "vault_dynamic" -backend_vault_path = "database/creds/pgdog" +server_vault_path = "database/creds/pgdog" # Refresh credentials after 80% of the lease has elapsed (default). # vault_refresh_percent = 80 ``` @@ -293,8 +293,8 @@ When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the follo Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role — they don't need to point at the same role, and each has its own username setting: -- `client_vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. -- `server_auth = "vault_static"` with `backend_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` or `name`, if `server_user` isn't set. +- `vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. +- `server_auth = "vault_static"` with `server_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` or `name`, if `server_user` isn't set. **Example** @@ -304,15 +304,15 @@ In `users.toml`, for a client authenticating as `alice` (verified against a stat [[users]] name = "alice" database = "pgdog" -client_vault_path = "database/static-creds/alice" +vault_path = "database/static-creds/alice" server_user = "pgdog_service" server_auth = "vault_static" -backend_vault_path = "database/static-creds/pgdog-service" +server_vault_path = "database/static-creds/pgdog-service" ``` In `pgdog.toml`, the same `[vault]` section used for dynamic credentials applies. -Both settings are optional and independent: set only `client_vault_path` to verify client passwords while keeping any other backend authentication method, or only `server_auth = "vault_static"` to use a static role for backend connections while clients authenticate with a regular password. +Both settings are optional and independent: set only `vault_path` to verify client passwords while keeping any other backend authentication method, or only `server_auth = "vault_static"` to use a static role for backend connections while clients authenticate with a regular password. ### Sharding diff --git a/example.pgdog.toml b/example.pgdog.toml index 2c2c5311b..a3c427d9e 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -474,7 +474,7 @@ fingerprint = "2d9944fc9caeaadd" # [3285733254894627549] # HashiCorp Vault settings, required when any user in users.toml # sets `server_auth = "vault_dynamic"` or `"vault_static"`, or configures -# `client_vault_path` for client-side static role password verification. +# `vault_path` for client-side static role password verification. # # [vault] # url = "https://vault.internal:8200" diff --git a/example.users.toml b/example.users.toml index eb2d5d274..7b586f25e 100644 --- a/example.users.toml +++ b/example.users.toml @@ -25,21 +25,21 @@ password = "pgdog" # Example: backend authentication with HashiCorp Vault dynamic credentials. # Requires the [vault] section in pgdog.toml. PgDog fetches a generated -# username and password from `backend_vault_path` and rotates them after +# username and password from `server_vault_path` and rotates them after # `vault_refresh_percent` of the lease has elapsed. # server_auth = "vault_dynamic" -# backend_vault_path = "database/creds/pgdog" +# server_vault_path = "database/creds/pgdog" # vault_refresh_percent = 80 # optional; default 80 # Example: client authentication against a HashiCorp Vault static database role. # Requires the [vault] section in pgdog.toml. PgDog verifies the password the -# client sends against Vault's current password for the static role at `client_vault_path`, +# client sends against Vault's current password for the static role at `vault_path`, # instead of a statically configured password. # Independent of `server_auth` below; combine it with any backend authentication method. -# client_vault_path = "database/static-creds/alice" +# vault_path = "database/static-creds/alice" # Example: backend authentication with a HashiCorp Vault static role. # Requires the [vault] section in pgdog.toml. # server_user = "pgdog_service" # server_auth = "vault_static" -# backend_vault_path = "database/static-creds/pgdog-service" +# server_vault_path = "database/static-creds/pgdog-service" diff --git a/integration/vault/users.toml b/integration/vault/users.toml index cdb5992f3..d6b87d845 100644 --- a/integration/vault/users.toml +++ b/integration/vault/users.toml @@ -3,13 +3,13 @@ name = "pgdog" database = "pgdog" password = "pgdog" server_auth = "vault_dynamic" -backend_vault_path = "database/creds/pgdog-role" +server_vault_path = "database/creds/pgdog-role" # Client password verified against Vault static role; backend also uses the # static role so the same Vault-managed password is used end-to-end. [[users]] name = "pgdog_static" database = "pgdog" -client_vault_path = "database/static-creds/pgdog-static-role" +vault_path = "database/static-creds/pgdog-static-role" server_auth = "vault_static" -backend_vault_path = "database/static-creds/pgdog-static-role" +server_vault_path = "database/static-creds/pgdog-static-role" diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 23d720c45..651d22d62 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -88,7 +88,7 @@ impl Users { ); } - if user.client_vault_path.is_some() && config.vault.is_none() { + if user.vault_path.is_some() && config.vault.is_none() { warn!( r#"user "{}" (database "{}") uses Vault client auth but the [vault] section is missing from pgdog.toml"#, user.name, user.database @@ -99,9 +99,9 @@ impl Users { user.server_auth, ServerAuth::VaultDynamic | ServerAuth::VaultStatic ) { - if user.backend_vault_path.is_none() { + if user.server_vault_path.is_none() { warn!( - r#"user "{}" (database "{}") uses "server_auth" = "{:?}" but "backend_vault_path" is not set"#, + r#"user "{}" (database "{}") uses "server_auth" = "{:?}" but "server_vault_path" is not set"#, user.name, user.database, user.server_auth ); } @@ -191,9 +191,6 @@ pub enum ServerAuth { AzureWorkloadIdentity, /// Fetch dynamic credentials from HashiCorp Vault (database secrets engine). /// Vault generates a new username and password on each lease. - /// - /// **Note:** `"vault"` is accepted as a deprecated alias for backward compatibility. - #[serde(alias = "vault")] VaultDynamic, /// Fetch credentials for a Vault static database role. /// Vault manages password rotation; the username is fixed. @@ -310,10 +307,7 @@ pub struct User { /// Vault path used to fetch backend (server-side) database credentials, /// e.g. `database/creds/my-role` for `server_auth = "vault_dynamic"` or /// `database/static-creds/my-role` for `server_auth = "vault_static"`. - /// - /// **Note:** `"vault_path"` is accepted as a deprecated alias for backward compatibility. - #[serde(alias = "vault_path")] - pub backend_vault_path: Option, + pub server_vault_path: Option, /// Percentage of the Vault credential lease after which credentials are refreshed. /// /// _Default:_ `80` @@ -322,7 +316,7 @@ pub struct User { /// e.g. `database/static-creds/my-role`. When set, PgDog fetches the /// current password from Vault and compares it to what the client /// provides instead of using a statically configured password. - pub client_vault_path: Option, + pub vault_path: Option, /// Statement timeout. /// /// Sets the `statement_timeout` on all server connections at connection creation. This allows you to set a reasonable default for each user without modifying `postgresql.conf` or using `ALTER USER`. @@ -401,7 +395,7 @@ impl User { if let Some(hash) = self.password_hash.clone() { passwords.push(PasswordKind::Hashed(hash)); } - if let Some(path) = self.client_vault_path.clone() { + if let Some(path) = self.vault_path.clone() { passwords.push(PasswordKind::VaultStaticRole(path)); } passwords @@ -712,7 +706,7 @@ server_auth = "azure_workload_identity" name = "alice" database = "db" server_auth = "vault_dynamic" -backend_vault_path = "database/creds/pgdog" +server_vault_path = "database/creds/pgdog" vault_refresh_percent = 75 "#; @@ -721,45 +715,12 @@ vault_refresh_percent = 75 assert_eq!(user.server_auth, ServerAuth::VaultDynamic); assert!(user.server_auth.is_external_identity()); assert_eq!( - user.backend_vault_path.as_deref(), + user.server_vault_path.as_deref(), Some("database/creds/pgdog") ); assert_eq!(user.vault_refresh_percent, Some(75)); } - #[test] - fn test_user_server_auth_vault_deprecated_alias() { - let source = r#" -[[users]] -name = "alice" -database = "db" -server_auth = "vault" -vault_path = "database/creds/pgdog" -"#; - - let users: Users = toml::from_str(source).unwrap(); - let user = users.users.first().unwrap(); - assert_eq!(user.server_auth, ServerAuth::VaultDynamic); - } - - #[test] - fn test_user_backend_vault_path_deprecated_alias() { - let source = r#" -[[users]] -name = "alice" -database = "db" -server_auth = "vault_dynamic" -vault_path = "database/creds/pgdog" -"#; - - let users: Users = toml::from_str(source).unwrap(); - let user = users.users.first().unwrap(); - assert_eq!( - user.backend_vault_path.as_deref(), - Some("database/creds/pgdog") - ); - } - #[test] fn test_user_server_auth_vault_static() { let source = r#" @@ -767,7 +728,7 @@ vault_path = "database/creds/pgdog" name = "alice" database = "db" server_auth = "vault_static" -backend_vault_path = "database/static-creds/my-role" +server_vault_path = "database/static-creds/my-role" vault_refresh_percent = 60 "#; @@ -776,18 +737,18 @@ vault_refresh_percent = 60 assert_eq!(user.server_auth, ServerAuth::VaultStatic); assert!(user.server_auth.is_external_identity()); assert_eq!( - user.backend_vault_path.as_deref(), + user.server_vault_path.as_deref(), Some("database/static-creds/my-role") ); assert_eq!(user.vault_refresh_percent, Some(60)); } #[test] - fn test_client_vault_path_appears_in_passwords_as_vault_static_role() { + fn test_vault_path_appears_in_passwords_as_vault_static_role() { let user = User { name: "alice".into(), database: "db".into(), - client_vault_path: Some("database/static-creds/alice-role".into()), + vault_path: Some("database/static-creds/alice-role".into()), ..Default::default() }; @@ -799,12 +760,12 @@ vault_refresh_percent = 60 } #[test] - fn test_client_vault_path_combined_with_static_password() { + fn test_vault_path_combined_with_static_password() { let user = User { name: "alice".into(), database: "db".into(), password: Some("fallback".into()), - client_vault_path: Some("database/static-creds/alice-role".into()), + vault_path: Some("database/static-creds/alice-role".into()), ..Default::default() }; @@ -831,7 +792,7 @@ vault_refresh_percent = 60 name: "alice".into(), database: "db".into(), server_auth: ServerAuth::VaultDynamic, - backend_vault_path: Some("database/creds/pgdog".into()), + server_vault_path: Some("database/creds/pgdog".into()), vault_refresh_percent: Some(150), ..Default::default() }], diff --git a/pgdog-config/src/vault.rs b/pgdog-config/src/vault.rs index 307c22d93..5ffc6b195 100644 --- a/pgdog-config/src/vault.rs +++ b/pgdog-config/src/vault.rs @@ -30,7 +30,7 @@ pub enum VaultAuthMethod { /// or `"vault_static"`. /// /// PgDog logs into Vault using the configured auth method and fetches -/// database credentials from the per-user `backend_vault_path`. +/// database credentials from the per-user `server_vault_path`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Vault { diff --git a/pgdog/src/auth/vault.rs b/pgdog/src/auth/vault.rs index 09206d755..42699d78d 100644 --- a/pgdog/src/auth/vault.rs +++ b/pgdog/src/auth/vault.rs @@ -3,7 +3,7 @@ //! This module owns the Vault login/token machinery shared by every Vault //! consumer in the codebase: //! -//! - **Client authentication** — users configured with `client_vault_path` +//! - **Client authentication** — users configured with `vault_path` //! retrieve their password from Vault and results are cached for the role's rotation TTL. //! - **Backend pools** (`src/backend/auth/vault.rs`) reuse the login/token //! cache here to fetch dynamic database credentials. diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 3fdea6085..1c16efe2c 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -52,7 +52,7 @@ fn vault_and_path(addr: &Address) -> Result<(pgdog_config::vault::Vault, &str), let path = addr.vault_path.as_deref().ok_or_else(|| { error(format!( - r#""vault_path" is not configured for {}@{}:{}"#, + r#""server_vault_path" is not configured for {}@{}:{}"#, addr.user, addr.host, addr.port )) })?; diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 0b2a999a4..b742fb166 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -95,7 +95,7 @@ impl Address { }, server_auth, server_iam_region: user.server_iam_region.clone(), - vault_path: user.backend_vault_path.clone(), + vault_path: user.server_vault_path.clone(), vault_refresh_percent: user.vault_refresh_percent, database_number, configured_role: database.role, @@ -545,7 +545,7 @@ mod test { let user = User { name: "pgdog".into(), server_auth: ServerAuth::VaultDynamic, - backend_vault_path: Some("database/creds/pgdog".into()), + server_vault_path: Some("database/creds/pgdog".into()), vault_refresh_percent: Some(50), password: Some("ignored".into()), database: "pgdog".into(), @@ -596,7 +596,7 @@ mod test { let user = User { name: "pgdog_static".into(), server_auth: ServerAuth::VaultStatic, - backend_vault_path: Some("database/static-creds/pgdog-static".into()), + server_vault_path: Some("database/static-creds/pgdog-static".into()), vault_refresh_percent: Some(70), password: Some("ignored".into()), database: "pgdog".into(), From 4ede7bd18d9d793ab8f103b131bd2a99cbcf0a93 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Anna Date: Sun, 5 Jul 2026 00:23:04 +0200 Subject: [PATCH 8/9] fix comments + instant --- README.md | 2 +- pgdog-config/src/users.rs | 17 ++- pgdog/src/auth/vault.rs | 193 ++++++++++++++++++++++++-- pgdog/src/backend/auth/vault.rs | 41 +++--- pgdog/src/backend/pool/address.rs | 6 +- pgdog/src/backend/pool/monitor.rs | 4 +- pgdog/src/backend/pool/token_cache.rs | 69 ++++----- pgdog/src/frontend/client/mod.rs | 32 +---- 8 files changed, 251 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index af4e6191f..06fef1c51 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the follo #### HashiCorp Vault static role authentication -Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role — they don't need to point at the same role, and each has its own username setting: +Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role,they don't need to point at the same role, and each has its own username setting: - `vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. - `server_auth = "vault_static"` with `server_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` or `name`, if `server_user` isn't set. diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 651d22d62..78a14202c 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -101,14 +101,14 @@ impl Users { ) { if user.server_vault_path.is_none() { warn!( - r#"user "{}" (database "{}") uses "server_auth" = "{:?}" but "server_vault_path" is not set"#, + r#"user "{}" (database "{}") uses "server_auth" = "{}" but "server_vault_path" is not set"#, user.name, user.database, user.server_auth ); } if config.vault.is_none() { warn!( - r#"user "{}" (database "{}") uses "server_auth" = "{:?}" but the [vault] section is missing from pgdog.toml"#, + r#"user "{}" (database "{}") uses "server_auth" = "{}" but the [vault] section is missing from pgdog.toml"#, user.name, user.database, user.server_auth ); } @@ -197,6 +197,19 @@ pub enum ServerAuth { VaultStatic, } +impl Display for ServerAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Password => "password", + Self::RdsIam => "rds_iam", + Self::AzureWorkloadIdentity => "azure_workload_identity", + Self::VaultDynamic => "vault_dynamic", + Self::VaultStatic => "vault_static", + }; + write!(f, "{}", s) + } +} + impl ServerAuth { pub fn is_external_identity(&self) -> bool { matches!( diff --git a/pgdog/src/auth/vault.rs b/pgdog/src/auth/vault.rs index 42699d78d..7b75667cc 100644 --- a/pgdog/src/auth/vault.rs +++ b/pgdog/src/auth/vault.rs @@ -9,17 +9,18 @@ //! cache here to fetch dynamic database credentials. use std::collections::HashMap; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant}; use once_cell::sync::Lazy; use parking_lot::Mutex; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::json; -use tracing::debug; +use tracing::{debug, warn}; use crate::backend::Error; use crate::config::config; +use pgdog_config::users::PasswordKind; use pgdog_config::vault::{Vault, VaultAuthMethod}; /// Cached Vault client token, shared by all pools. @@ -28,7 +29,7 @@ pub(crate) static VAULT_TOKEN: Lazy>> = Lazy::new(|| Mu #[derive(Clone, Debug)] pub(crate) struct VaultToken { pub(crate) token: String, - pub(crate) expires_at: SystemTime, + pub(crate) expires_at: Instant, } #[derive(Deserialize)] @@ -45,7 +46,7 @@ struct AuthData { /// Static role client-auth cache struct CachedStaticPassword { password: String, - expires_at: SystemTime, + expires_at: Instant, } static CLIENT_PASSWORD_CACHE: Lazy>> = @@ -168,7 +169,7 @@ pub(crate) async fn login(vault: &Vault) -> Result { Ok(VaultToken { token: auth.auth.client_token, - expires_at: SystemTime::now() + Duration::from_secs(auth.auth.lease_duration), + expires_at: Instant::now() + Duration::from_secs(auth.auth.lease_duration), }) } @@ -176,7 +177,7 @@ pub(crate) async fn login(vault: &Vault) -> Result { /// missing or about to expire. pub(crate) async fn vault_token(vault: &Vault) -> Result { if let Some(cached) = VAULT_TOKEN.lock().clone() - && SystemTime::now() + vault.token_expiry_buffer() < cached.expires_at + && Instant::now() + vault.token_expiry_buffer() < cached.expires_at { return Ok(cached.token); } @@ -235,7 +236,7 @@ pub(crate) async fn fetch_secret( /// per-path cache keyed by `vault_path`. pub(crate) async fn static_client_password(vault_path: &str) -> Result { if let Some(cached) = CLIENT_PASSWORD_CACHE.lock().get(vault_path) - && SystemTime::now() < cached.expires_at + && Instant::now() < cached.expires_at { return Ok(cached.password.clone()); } @@ -249,7 +250,7 @@ pub(crate) async fn static_client_password(vault_path: &str) -> Result Result Vec { + let mut buf = Vec::with_capacity(passwords.len()); + for p in passwords { + match p { + PasswordKind::VaultStaticRole(path) => match static_client_password(path).await { + Ok(pw) => buf.push(PasswordKind::Plain(pw)), + Err(err) => { + warn!(vault_path = path, %err, "failed to resolve Vault password, skipping") + } + }, + other => buf.push(other.clone()), + } + } + buf +} + #[cfg(test)] mod tests { - use std::time::{Duration, SystemTime}; + use std::time::{Duration, Instant}; use pgdog_config::vault::{Vault, VaultAuthMethod}; use serde_json::json; @@ -327,7 +348,7 @@ mod tests { "database/static-creds/cached-role".into(), CachedStaticPassword { password: "cached-pw".into(), - expires_at: SystemTime::now() + Duration::from_secs(3600), + expires_at: Instant::now() + Duration::from_secs(3600), }, ); @@ -348,7 +369,7 @@ mod tests { "database/static-creds/expired-role".into(), CachedStaticPassword { password: "stale-pw".into(), - expires_at: SystemTime::now() - Duration::from_secs(1), + expires_at: Instant::now() - Duration::from_secs(1), }, ); @@ -426,7 +447,7 @@ mod tests { *VAULT_TOKEN.lock() = Some(VaultToken { token: "s.stale".into(), - expires_at: SystemTime::now() + Duration::from_secs(3600), + expires_at: Instant::now() + Duration::from_secs(3600), }); Mock::given(method("GET")) @@ -565,7 +586,7 @@ mod tests { let token = login(&vault).await.unwrap(); assert_eq!(token.token, "s.abc123"); - assert!(token.expires_at > SystemTime::now()); + assert!(token.expires_at > Instant::now()); } #[tokio::test] @@ -594,7 +615,7 @@ mod tests { async fn test_vault_token_uses_cached() { *VAULT_TOKEN.lock() = Some(VaultToken { token: "s.cached".into(), - expires_at: SystemTime::now() + Duration::from_secs(3600), + expires_at: Instant::now() + Duration::from_secs(3600), }); // Port 1 is unreachable; proves no HTTP call was made. @@ -627,4 +648,148 @@ mod tests { setup(); assert!(client(&approle_vault("http://127.0.0.1:8200")).is_ok()); } + + // ── resolve_passwords() ──────────────────────────────────────────────────── + + #[tokio::test] + async fn test_resolve_passwords_no_vault_roles_passthrough() { + let passwords = vec![ + PasswordKind::Plain("secret".into()), + PasswordKind::Hashed("$2b$12$hash".into()), + ]; + let result = resolve_passwords(&passwords).await; + assert_eq!(result, passwords); + } + + #[tokio::test] + async fn test_resolve_passwords_empty() { + assert!(resolve_passwords(&[]).await.is_empty()); + } + + #[tokio::test] + async fn test_resolve_passwords_resolves_vault_role() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.resolve1", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/my-role")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "username": "pgdog", "password": "vault-pw", "ttl": 3600 } + }))) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + CLIENT_PASSWORD_CACHE + .lock() + .remove("database/static-creds/my-role"); + set_vault_config(approle_vault(&server.uri())); + + let passwords = vec![PasswordKind::VaultStaticRole( + "database/static-creds/my-role".into(), + )]; + let result = resolve_passwords(&passwords).await; + assert_eq!(result, vec![PasswordKind::Plain("vault-pw".into())]); + } + + #[tokio::test] + async fn test_resolve_passwords_mixed_resolves_only_vault() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.resolve2", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/mixed-role")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "username": "pgdog", "password": "mixed-pw", "ttl": 3600 } + }))) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + CLIENT_PASSWORD_CACHE + .lock() + .remove("database/static-creds/mixed-role"); + set_vault_config(approle_vault(&server.uri())); + + let passwords = vec![ + PasswordKind::Plain("static-pw".into()), + PasswordKind::VaultStaticRole("database/static-creds/mixed-role".into()), + ]; + let result = resolve_passwords(&passwords).await; + assert_eq!( + result, + vec![ + PasswordKind::Plain("static-pw".into()), + PasswordKind::Plain("mixed-pw".into()), + ] + ); + } + + #[tokio::test] + async fn test_resolve_passwords_vault_error_skips_entry() { + setup(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/auth/approle/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "auth": { "client_token": "s.resolve3", "lease_duration": 3600 } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/database/static-creds/bad-role")) + .respond_with(ResponseTemplate::new(500).set_body_string("vault internal error")) + .mount(&server) + .await; + + let _guard = crate::test_utils::set_env_var("VAULT_SECRET_ID", "my-secret"); + *VAULT_TOKEN.lock() = None; + CLIENT_PASSWORD_CACHE + .lock() + .remove("database/static-creds/bad-role"); + set_vault_config(approle_vault(&server.uri())); + + // Vault entry is skipped; the plain fallback survives. + let passwords = vec![ + PasswordKind::VaultStaticRole("database/static-creds/bad-role".into()), + PasswordKind::Plain("fallback-pw".into()), + ]; + let result = resolve_passwords(&passwords).await; + assert_eq!(result, vec![PasswordKind::Plain("fallback-pw".into())]); + } + + #[tokio::test] + async fn test_resolve_passwords_all_vault_errors_yields_empty() { + crate::config::set(ConfigAndUsers::default()).unwrap(); + + // No vault config → static_client_password errors for every entry. + let passwords = vec![PasswordKind::VaultStaticRole( + "database/static-creds/no-config-role".into(), + )]; + let result = resolve_passwords(&passwords).await; + assert!( + result.is_empty(), + "all vault entries failed, expected empty vec" + ); + } } diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 1c16efe2c..297a74af0 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -19,7 +19,7 @@ //! The Vault login/token cache itself lives in //! [`crate::auth::vault`], shared with static role client-auth verification. -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant}; use serde::Deserialize; use tracing::info; @@ -62,11 +62,7 @@ fn vault_and_path(addr: &Address) -> Result<(pgdog_config::vault::Vault, &str), /// Compute when to refresh a credential given its total lifetime, clamping /// `refresh_percent` to the 1-80% range. -fn scheduled_refresh( - now: SystemTime, - lifetime: Duration, - refresh_percent: Option, -) -> SystemTime { +fn scheduled_refresh(now: Instant, lifetime: Duration, refresh_percent: Option) -> Instant { let refresh_percent = refresh_percent .unwrap_or(DEFAULT_REFRESH_PERCENT) .clamp(1, 80); @@ -94,7 +90,7 @@ pub(crate) async fn credentials(addr: Address) -> Result Result Result<(String, SystemTime, SystemTime), Error> { +/// Returns `(password, refresh_at)`. +/// `refresh_at` clamped to at least [`STATIC_ROLE_MIN_REFRESH_BACKOFF`] +/// from now, since Vault won't have a fresh password to give out before then. +pub(crate) async fn static_backend_credentials(addr: Address) -> Result<(String, Instant), Error> { let (vault, path) = vault_and_path(&addr)?; let secret: StaticSecretResponse = fetch_secret(&vault, path).await?; @@ -141,11 +134,11 @@ pub(crate) async fn static_backend_credentials( "fetched Vault static backend credentials" ); - let now = SystemTime::now(); - let expires_at = now + Duration::from_secs(secret.data.ttl); - let refresh_at = expires_at.max(now + STATIC_ROLE_MIN_REFRESH_BACKOFF); + let now = Instant::now(); + let refresh_at = + (now + Duration::from_secs(secret.data.ttl)).max(now + STATIC_ROLE_MIN_REFRESH_BACKOFF); - Ok((secret.data.password, expires_at, refresh_at)) + Ok((secret.data.password, refresh_at)) } #[cfg(test)] @@ -259,7 +252,6 @@ mod tests { .unwrap(); assert_eq!(fetched.credentials.username.as_deref(), Some("v-pgdog-abc")); assert_eq!(fetched.credentials.secret, "super-secret"); - assert!(fetched.expires_at > SystemTime::now()); assert!(fetched.refresh_at.is_some()); } @@ -270,7 +262,7 @@ mod tests { *VAULT_TOKEN.lock() = Some(VaultToken { token: "s.stale".into(), - expires_at: SystemTime::now() + Duration::from_secs(3600), + expires_at: Instant::now() + Duration::from_secs(3600), }); Mock::given(method("GET")) @@ -377,13 +369,12 @@ mod tests { *VAULT_TOKEN.lock() = None; set_vault_config(approle_vault(&server.uri())); - let (password, expires_at, refresh_at) = + let (password, refresh_at) = static_backend_credentials(make_addr(Some("database/static-creds/pgdog-static-role"))) .await .unwrap(); assert_eq!(password, "vault-rotated-pass"); - assert!(expires_at > SystemTime::now()); - assert!(refresh_at > SystemTime::now()); + assert!(refresh_at > Instant::now()); } #[tokio::test] @@ -393,7 +384,7 @@ mod tests { *VAULT_TOKEN.lock() = Some(VaultToken { token: "s.stale".into(), - expires_at: SystemTime::now() + Duration::from_secs(3600), + expires_at: Instant::now() + Duration::from_secs(3600), }); Mock::given(method("GET")) diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index b742fb166..b3a875294 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -234,7 +234,7 @@ impl TryFrom for Address { #[cfg(test)] mod test { - use std::time::{Duration, SystemTime}; + use std::time::{Duration, Instant, SystemTime}; use crate::config; @@ -503,7 +503,7 @@ mod test { ..Default::default() }; - let now = SystemTime::now(); + let now = Instant::now(); TokenCache::global().set_credentials( &addr, FetchedCredentials { @@ -511,7 +511,7 @@ mod test { username: Some("v-generated-user".into()), secret: "v-generated-pass".into(), }, - expires_at: now + Duration::from_secs(3600), + expires_at: None, refresh_at: Some(now + Duration::from_secs(2880)), }, ); diff --git a/pgdog/src/backend/pool/monitor.rs b/pgdog/src/backend/pool/monitor.rs index 7f001079b..0014b8b22 100644 --- a/pgdog/src/backend/pool/monitor.rs +++ b/pgdog/src/backend/pool/monitor.rs @@ -206,9 +206,9 @@ impl Monitor { } ServerAuth::VaultStatic => { vault::static_backend_credentials(addr.clone()).await.map( - |(token, expires_at, refresh_at)| { + |(token, refresh_at)| { TokenCache::global().set_with_refresh_at( - &addr, token, expires_at, refresh_at, + &addr, token, refresh_at, ) }, ) diff --git a/pgdog/src/backend/pool/token_cache.rs b/pgdog/src/backend/pool/token_cache.rs index 8ea6b78c5..f751c12f3 100644 --- a/pgdog/src/backend/pool/token_cache.rs +++ b/pgdog/src/backend/pool/token_cache.rs @@ -2,7 +2,7 @@ use once_cell::sync::Lazy; use parking_lot::Mutex; use std::collections::HashMap; use std::future::Future; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant, SystemTime}; use crate::backend::{Error, pool::Address}; @@ -31,15 +31,15 @@ pub struct Credentials { #[derive(Clone, Debug)] pub struct FetchedCredentials { pub credentials: Credentials, - pub expires_at: SystemTime, - pub refresh_at: Option, + pub expires_at: Option, + pub refresh_at: Option, } #[derive(Clone)] struct CachedToken { credentials: Credentials, - expires_at: SystemTime, - refresh_at: Option, + expires_at: Option, + refresh_at: Option, } impl CachedToken { @@ -49,7 +49,7 @@ impl CachedToken { username: None, secret: token, }, - expires_at, + expires_at: Some(expires_at), refresh_at: None, } } @@ -127,7 +127,7 @@ impl TokenCache { self.inner .lock() .get(&CacheKey::from(addr)) - .map(|c| c.expires_at) + .and_then(|c| c.expires_at) } /// How long the monitor should sleep before waking up to refresh the @@ -150,17 +150,15 @@ impl TokenCache { // An explicit refresh instant (e.g. a percentage of a Vault lease) // takes precedence over the expiry buffer. if let Some(refresh_at) = refresh_at { - return refresh_at - .duration_since(SystemTime::now()) - .unwrap_or(Duration::ZERO); + refresh_at.duration_since(Instant::now()) + } else { + // If the token is already expired or expires within the buffer, + // fetch immediately. + expires_at + .and_then(|t| t.checked_sub(EXPIRY_BUFFER)) + .and_then(|t| t.duration_since(SystemTime::now()).ok()) + .unwrap_or(Duration::ZERO) } - - // If the token is already expired or expires within the buffer, - // fetch immediately. - expires_at - .checked_sub(EXPIRY_BUFFER) - .and_then(|refresh_at| refresh_at.duration_since(SystemTime::now()).ok()) - .unwrap_or(Duration::ZERO) } /// Store a freshly fetched token for `addr`. @@ -179,13 +177,7 @@ impl TokenCache { /// default [`EXPIRY_BUFFER`] — e.g. a Vault static role, which only /// issues a new password once its TTL actually expires and echoes back /// a shrinking TTL if read early, resulting in a tight refresh loop. - pub fn set_with_refresh_at( - &self, - addr: &Address, - token: String, - expires_at: SystemTime, - refresh_at: SystemTime, - ) { + pub fn set_with_refresh_at(&self, addr: &Address, token: String, refresh_at: Instant) { self.inner.lock().insert( CacheKey::from(addr), CachedToken { @@ -193,7 +185,7 @@ impl TokenCache { username: None, secret: token, }, - expires_at, + expires_at: None, refresh_at: Some(refresh_at), }, ); @@ -247,7 +239,7 @@ impl TokenCache { ) -> Result where F: Fn(Address) -> Fut + Send + Sync, - Fut: Future>, + Fut: Future>, { if let Some(cached) = self.inner.lock().get(&CacheKey::from(addr)).cloned() { return Ok(cached.credentials.secret); @@ -255,8 +247,8 @@ impl TokenCache { // Cold miss — block once to prime the cache. // After this the monitor's refresh loop takes over. - let (token, expires_at, refresh_at) = fetcher(addr.clone()).await?; - self.set_with_refresh_at(addr, token.clone(), expires_at, refresh_at); + let (token, refresh_at) = fetcher(addr.clone()).await?; + self.set_with_refresh_at(addr, token.clone(), refresh_at); Ok(token) } @@ -372,7 +364,7 @@ mod tests { #[test] fn refresh_in_uses_explicit_refresh_at_when_set() { let a = addr(9919); - let now = SystemTime::now(); + let now = Instant::now(); cache().set_credentials( &a, FetchedCredentials { @@ -380,8 +372,7 @@ mod tests { username: Some("vault-user".into()), secret: "vault-pass".into(), }, - expires_at: now + Duration::from_secs(3600), - // Refresh well before the expiry buffer would. + expires_at: None, refresh_at: Some(now + Duration::from_secs(100)), }, ); @@ -394,7 +385,7 @@ mod tests { #[test] fn refresh_in_returns_zero_for_past_refresh_at() { let a = addr(9920); - let now = SystemTime::now(); + let now = Instant::now(); cache().set_credentials( &a, FetchedCredentials { @@ -402,7 +393,7 @@ mod tests { username: None, secret: "tok".into(), }, - expires_at: now + Duration::from_secs(3600), + expires_at: None, refresh_at: Some(now - Duration::from_secs(10)), }, ); @@ -414,7 +405,7 @@ mod tests { fn credentials_or_fetch_returns_cached_username() { let rt = tokio::runtime::Runtime::new().unwrap(); let a = addr(9921); - let now = SystemTime::now(); + let now = Instant::now(); cache().set_credentials( &a, FetchedCredentials { @@ -422,8 +413,8 @@ mod tests { username: Some("v-user".into()), secret: "v-pass".into(), }, - expires_at: now + Duration::from_secs(3600), - refresh_at: None, + expires_at: None, + refresh_at: Some(now + Duration::from_secs(3600)), }, ); @@ -452,8 +443,8 @@ mod tests { username: Some("fresh-user".into()), secret: "fresh-pass".into(), }, - expires_at: now + Duration::from_secs(3600), - refresh_at: Some(now + Duration::from_secs(2880)), + expires_at: Some(now + Duration::from_secs(3600)), + refresh_at: Some(Instant::now() + Duration::from_secs(2880)), }) })) .unwrap(); @@ -476,7 +467,7 @@ mod tests { username: Some("u".into()), secret: "s".into(), }, - expires_at: SystemTime::now() + Duration::from_secs(3600), + expires_at: Some(SystemTime::now() + Duration::from_secs(3600)), refresh_at: None, }, ); diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index dd83bf850..ff7444c93 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -160,30 +160,6 @@ impl Client { return Ok(AuthResult::NoPasswordConfig); } - // Resolve any Vault static role entries to plaintext before starting - // the auth exchange. MD5, SCRAM, and plain all need the actual - // password, so this must happen before the first message is sent. - let resolved; - let passwords: &[PasswordKind] = if passwords - .iter() - .any(|p| matches!(p, PasswordKind::VaultStaticRole(_))) - { - let mut buf = Vec::with_capacity(passwords.len()); - for p in passwords { - match p { - PasswordKind::VaultStaticRole(path) => { - let pw = crate::auth::vault::static_client_password(path).await?; - buf.push(PasswordKind::Plain(pw)); - } - other => buf.push(other.clone()), - } - } - resolved = buf; - &resolved - } else { - passwords - }; - let result = match auth_type { AuthType::Md5 => { let md5 = md5::Client::new( @@ -304,9 +280,11 @@ impl Client { AuthResult::NoIdentity } } else { - // Password authentication. - Self::check_password(&mut stream, user, auth_type, cluster.passwords()) - .await? + // Resolve Vault static role + // entries to plaintext before the auth exchange + let passwords = + crate::auth::vault::resolve_passwords(cluster.passwords()).await; + Self::check_password(&mut stream, user, auth_type, &passwords).await? } } From dd074c2ecbaabad17dc36a2cd2e752beab0c361c Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Mon, 6 Jul 2026 07:58:48 -0700 Subject: [PATCH 9/9] Space after comma Co-authored-by: Lev Kokotov --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 06fef1c51..14c210042 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ When any user has `server_auth = "vault_dynamic"` or `"vault_static"`, the follo #### HashiCorp Vault static role authentication -Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role,they don't need to point at the same role, and each has its own username setting: +Unlike dynamic credentials, a Vault static database role has a fixed username and only its password rotates, on a schedule Vault manages. PgDog supports two independent uses of a static role, they don't need to point at the same role, and each has its own username setting: - `vault_path`: verify the password a client sends to PgDog against Vault's current password for the role, instead of a statically configured password. - `server_auth = "vault_static"` with `server_vault_path`: use the role's Vault-managed password for PgDog-to-PostgreSQL connections. Unlike `vault_dynamic`, PgDog doesn't take the username from Vault, it connects as `server_user` or `name`, if `server_user` isn't set.