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/pgdog.schema.json b/.schema/pgdog.schema.json index 90756da82..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\"`.\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 `server_vault_path`.", "type": "object", "properties": { "approle_role_id": { diff --git a/.schema/users.schema.json b/.schema/users.schema.json index 48c4b4b57..f0c54fad1 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.", "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" } ] }, @@ -270,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": [ @@ -294,7 +306,7 @@ ] }, "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`.", + "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" diff --git a/README.md b/README.md index ad535ba62..14c210042 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. @@ -266,8 +267,8 @@ In `users.toml`: name = "alice" database = "pgdog" password = "client-password" -server_auth = "vault" -vault_path = "database/creds/pgdog" +server_auth = "vault_dynamic" +server_vault_path = "database/creds/pgdog" # Refresh credentials after 80% of the lease has elapsed (default). # vault_refresh_percent = 80 ``` @@ -283,11 +284,36 @@ 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"`. +#### 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: + +- `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** + +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" +vault_path = "database/static-creds/alice" +server_user = "pgdog_service" +server_auth = "vault_static" +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 `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 📘 **[Sharding](https://docs.pgdog.dev/features/sharding/)** diff --git a/example.pgdog.toml b/example.pgdog.toml index 68e42e551..a3c427d9e 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"`. +# sets `server_auth = "vault_dynamic"` or `"vault_static"`, or configures +# `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 6fb74814f..7b586f25e 100644 --- a/example.users.toml +++ b/example.users.toml @@ -25,8 +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 `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" -# vault_path = "database/creds/pgdog" +# server_auth = "vault_dynamic" +# 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 `vault_path`, +# instead of a statically configured password. +# Independent of `server_auth` below; combine it with any backend authentication method. +# 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" +# server_vault_path = "database/static-creds/pgdog-service" diff --git a/integration/vault/setup.sh b/integration/vault/setup.sh index 71b6f5228..6f5a1eb58 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=300 + 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..d6b87d845 100644 --- a/integration/vault/users.toml +++ b/integration/vault/users.toml @@ -2,5 +2,14 @@ name = "pgdog" database = "pgdog" password = "pgdog" -server_auth = "vault" -vault_path = "database/creds/pgdog-role" +server_auth = "vault_dynamic" +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" +vault_path = "database/static-creds/pgdog-static-role" +server_auth = "vault_static" +server_vault_path = "database/static-creds/pgdog-static-role" diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 37233f322..78a14202c 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -88,18 +88,28 @@ impl Users { ); } - if user.server_auth == ServerAuth::Vault { - if user.vault_path.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 + ); + } + + if matches!( + user.server_auth, + ServerAuth::VaultDynamic | ServerAuth::VaultStatic + ) { + if user.server_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 "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" = "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 ); } @@ -179,15 +189,32 @@ 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. + VaultDynamic, + /// Fetch credentials for a Vault static database role. + /// Vault manages password rotation; the username is fixed. + 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!( self, - Self::RdsIam | Self::AzureWorkloadIdentity | Self::Vault + Self::RdsIam | Self::AzureWorkloadIdentity | Self::VaultDynamic | Self::VaultStatic ) } } @@ -197,6 +224,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 +237,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 +247,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), } } } @@ -282,13 +317,19 @@ 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"`. + pub server_vault_path: Option, /// Percentage of the Vault credential lease after which credentials are refreshed. /// /// _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 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 +405,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.vault_path.clone() { + passwords.push(PasswordKind::VaultStaticRole(path)); + } passwords } @@ -671,32 +713,99 @@ 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" +server_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.server_vault_path.as_deref(), + Some("database/creds/pgdog") + ); assert_eq!(user.vault_refresh_percent, Some(75)); } + #[test] + fn test_user_server_auth_vault_static() { + let source = r#" +[[users]] +name = "alice" +database = "db" +server_auth = "vault_static" +server_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.server_vault_path.as_deref(), + Some("database/static-creds/my-role") + ); + assert_eq!(user.vault_refresh_percent, Some(60)); + } + + #[test] + fn test_vault_path_appears_in_passwords_as_vault_static_role() { + let user = User { + name: "alice".into(), + database: "db".into(), + 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_vault_path_combined_with_static_password() { + let user = User { + name: "alice".into(), + database: "db".into(), + password: Some("fallback".into()), + 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, + 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 3754b0cc3..5ffc6b195 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 `server_vault_path`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Vault { 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..7b75667cc --- /dev/null +++ b/pgdog/src/auth/vault.rs @@ -0,0 +1,795 @@ +//! 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 `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. + +use std::collections::HashMap; +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, 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. +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: Instant, +} + +#[derive(Deserialize)] +struct AuthResponse { + auth: AuthData, +} + +#[derive(Deserialize)] +struct AuthData { + client_token: String, + lease_duration: u64, +} + +/// Static role client-auth cache +struct CachedStaticPassword { + password: String, + expires_at: Instant, +} + +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. +#[derive(Deserialize)] +pub(crate) struct StaticSecretResponse { + pub(crate) data: StaticSecretData, +} + +#[derive(Deserialize)] +pub(crate) struct StaticSecretData { + pub(crate) password: String, + /// Seconds until Vault rotates the password. + pub(crate) 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: Instant::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() + && Instant::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 and parse a JSON secret from `path` in Vault. +/// +/// 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('/'), + path.trim_start_matches('/') + ); + + let response = client(vault)? + .get(&url) + .header("X-Vault-Token", token) + .send() + .await + .map_err(|err| error(format!("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!( + "read at \"{}\" returned {}: {}", + url, status, body + ))); + } + + response + .json() + .await + .map_err(|err| error(format!("{} invalid response: {}", url, err))) +} + +/// Return the current password for a Vault static database role, using a +/// 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) + && Instant::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 = Instant::now() + ttl; + + 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) +} + +/// Resolve any [`PasswordKind::VaultStaticRole`] entries in `passwords` to +/// [`PasswordKind::Plain`] by fetching them from Vault. Non-Vault entries are +/// passed through unchanged. Failed Vault fetches are logged and skipped so +/// that any remaining plain/hashed passwords can still authenticate the client. +pub(crate) async fn resolve_passwords(passwords: &[PasswordKind]) -> 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, Instant}; + + 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: Instant::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: Instant::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": { "username": "pgdog-static", "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": { "username": "pgdog-static", "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: Instant::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 > Instant::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: Instant::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()); + } + + // ── 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 ad2f1dc3f..297a74af0 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -1,44 +1,34 @@ -//! HashiCorp Vault dynamic database credentials. +//! HashiCorp Vault dynamic and static database credentials for backend pools. //! -//! 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. +//! Pools configured with `server_auth = "vault_dynamic"` fetch their +//! 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_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. -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant}; -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::{StaticSecretResponse, error, fetch_secret}; 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)); - -#[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 { @@ -52,134 +42,8 @@ struct SecretData { password: String, } -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`] -/// 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 @@ -188,50 +52,34 @@ pub(crate) async fn credentials(addr: Address) -> Result) -> Instant { + 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); @@ -242,27 +90,59 @@ pub(crate) async fn credentials(addr: Address) -> Result Result<(String, Instant), Error> { + let (vault, path) = vault_and_path(&addr)?; + + let secret: StaticSecretResponse = fetch_secret(&vault, path).await?; + + info!( + user = %addr.user, + ttl_secs = secret.data.ttl, + "fetched Vault static backend credentials" + ); + + 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, refresh_at)) +} + #[cfg(test)] mod tests { - use std::time::{Duration, SystemTime}; - use pgdog_config::Role; use pgdog_config::vault::{Vault, VaultAuthMethod}; use serde_json::json; @@ -270,6 +150,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; + use crate::auth::vault::{VAULT_TOKEN, VaultToken}; use crate::config::ConfigAndUsers; fn setup() { @@ -338,135 +219,133 @@ mod tests { ); } - // ── login(): parameter validation ───────────────────────────────────────── + // ── credentials(): HTTP responses ───────────────────────────────────────── #[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, - }; + async fn test_credentials_success() { + setup(); + let server = MockServer::start().await; - let err = login(&vault).await.unwrap_err(); - assert!( - err.to_string().contains("approle_role_id"), - "unexpected error: {err}" - ); - } + 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; - #[tokio::test] - async fn test_login_approle_missing_secret_id() { - let _guard = crate::test_utils::remove_env_var("VAULT_SECRET_ID"); + Mock::given(method("GET")) + .and(path("/v1/database/creds/pgdog-role")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "lease_duration": 600, + "data": { "username": "v-pgdog-abc", "password": "super-secret" } + }))) + .mount(&server) + .await; - 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 _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 = login(&vault).await.unwrap_err(); - assert!( - err.to_string().contains("VAULT_SECRET_ID"), - "unexpected error: {err}" - ); + let fetched = credentials(make_addr(Some("database/creds/pgdog-role"))) + .await + .unwrap(); + assert_eq!(fetched.credentials.username.as_deref(), Some("v-pgdog-abc")); + assert_eq!(fetched.credentials.secret, "super-secret"); + assert!(fetched.refresh_at.is_some()); } #[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, - }; + async fn test_credentials_forbidden_clears_token_cache() { + setup(); + let server = MockServer::start().await; + + *VAULT_TOKEN.lock() = Some(VaultToken { + token: "s.stale".into(), + expires_at: Instant::now() + Duration::from_secs(3600), + }); + + Mock::given(method("GET")) + .and(path("/v1/database/creds/pgdog-role")) + .respond_with(ResponseTemplate::new(403).set_body_string("token revoked")) + .mount(&server) + .await; + + set_vault_config(approle_vault(&server.uri())); - let err = login(&vault).await.unwrap_err(); + let err = credentials(make_addr(Some("database/creds/pgdog-role"))) + .await + .unwrap_err(); + assert!(err.to_string().contains("403"), "unexpected error: {err}"); assert!( - err.to_string().contains("kubernetes_role"), - "unexpected error: {err}" + VAULT_TOKEN.lock().is_none(), + "token cache should be cleared after 403" ); } - // ── login(): HTTP responses ──────────────────────────────────────────────── - #[tokio::test] - async fn test_login_approle_success() { + async fn test_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.abc123", "lease_duration": 3600 } + "auth": { "client_token": "s.tok2", "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")) + Mock::given(method("GET")) + .and(path("/v1/database/creds/pgdog-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", "bad-secret"); - let vault = approle_vault(&server.uri()); + 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 = login(&vault).await.unwrap_err(); + let err = credentials(make_addr(Some("database/creds/pgdog-role"))) + .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}"); + assert!(msg.contains("500"), "expected 500 in: {msg}"); + assert!(msg.contains("internal error"), "expected body in: {msg}"); } - // ── vault_token(): cache behaviour ──────────────────────────────────────── + // ── static_backend_credentials(): config-level error cases ──────────────── #[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), - }); + async fn test_static_backend_credentials_no_vault_config() { + crate::config::set(ConfigAndUsers::default()).unwrap(); - // 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"); + 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}" + ); } - // ── credentials(): HTTP responses ───────────────────────────────────────── + #[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_credentials_success() { + async fn test_static_backend_credentials_success() { setup(); let server = MockServer::start().await; @@ -479,10 +358,9 @@ mod tests { .await; Mock::given(method("GET")) - .and(path("/v1/database/creds/pgdog-role")) + .and(path("/v1/database/static-creds/pgdog-static-role")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "lease_duration": 600, - "data": { "username": "v-pgdog-abc", "password": "super-secret" } + "data": { "username": "testuser", "password": "vault-rotated-pass", "ttl": 600 } }))) .mount(&server) .await; @@ -491,36 +369,36 @@ mod tests { *VAULT_TOKEN.lock() = None; set_vault_config(approle_vault(&server.uri())); - let fetched = credentials(make_addr(Some("database/creds/pgdog-role"))) - .await - .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()); + 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!(refresh_at > Instant::now()); } #[tokio::test] - async fn test_credentials_forbidden_clears_token_cache() { + 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), + expires_at: Instant::now() + Duration::from_secs(3600), }); Mock::given(method("GET")) - .and(path("/v1/database/creds/pgdog-role")) + .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 = credentials(make_addr(Some("database/creds/pgdog-role"))) - .await - .unwrap_err(); + 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(), @@ -529,7 +407,7 @@ mod tests { } #[tokio::test] - async fn test_credentials_error_response() { + async fn test_static_backend_credentials_error_response() { setup(); let server = MockServer::start().await; @@ -542,7 +420,7 @@ mod tests { .await; Mock::given(method("GET")) - .and(path("/v1/database/creds/pgdog-role")) + .and(path("/v1/database/static-creds/pgdog-static-role")) .respond_with(ResponseTemplate::new(500).set_body_string("internal error")) .mount(&server) .await; @@ -551,36 +429,12 @@ mod tests { *VAULT_TOKEN.lock() = None; set_vault_config(approle_vault(&server.uri())); - let err = credentials(make_addr(Some("database/creds/pgdog-role"))) - .await - .unwrap_err(); + 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}"); } - - // ── 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/pool/address.rs b/pgdog/src/backend/pool/address.rs index f28d51419..b3a875294 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.server_vault_path.clone(), vault_refresh_percent: user.vault_refresh_percent, database_number, configured_role: database.role, @@ -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> { @@ -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,13 @@ impl Address { } vec![Password::new(&credentials.secret, PasswordSource::Vault)] } + + ServerAuth::VaultStatic => { + let password = TokenCache::global() + .get_or_fetch_with_refresh(self, vault::static_backend_credentials) + .await?; + vec![Password::new(&password, PasswordSource::Vault)] + } }; // Give the valid password first. @@ -227,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; @@ -491,12 +498,12 @@ 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() }; - let now = SystemTime::now(); + let now = Instant::now(); TokenCache::global().set_credentials( &addr, FetchedCredentials { @@ -504,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)), }, ); @@ -537,8 +544,8 @@ mod test { let user = User { name: "pgdog".into(), - server_auth: ServerAuth::Vault, - vault_path: Some("database/creds/pgdog".into()), + server_auth: ServerAuth::VaultDynamic, + server_vault_path: Some("database/creds/pgdog".into()), vault_refresh_percent: Some(50), password: Some("ignored".into()), database: "pgdog".into(), @@ -550,11 +557,65 @@ 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_password_from_cache() { + let addr = Address { + host: "auth-secrets-vault-static.internal".into(), + port: 15436, + user: "pgdog_static".into(), + server_auth: ServerAuth::VaultStatic, + vault_path: Some("database/static-creds/pgdog-static".into()), + ..Default::default() + }; + + 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); + + 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, + server_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/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/backend/pool/monitor.rs b/pgdog/src/backend/pool/monitor.rs index f9085ace7..0014b8b22 100644 --- a/pgdog/src/backend/pool/monitor.rs +++ b/pgdog/src/backend/pool/monitor.rs @@ -204,7 +204,16 @@ impl Monitor { }, ) } - ServerAuth::Vault => { + ServerAuth::VaultStatic => { + vault::static_backend_credentials(addr.clone()).await.map( + |(token, refresh_at)| { + TokenCache::global().set_with_refresh_at( + &addr, token, refresh_at, + ) + }, + ) + } + ServerAuth::VaultDynamic => { vault::credentials(addr.clone()).await.map(|credentials| { TokenCache::global().set_credentials(&addr, credentials); pool.lock().bump_credentials_generation(); diff --git a/pgdog/src/backend/pool/token_cache.rs b/pgdog/src/backend/pool/token_cache.rs index e92f337b5..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`. @@ -172,6 +170,27 @@ 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, refresh_at: Instant) { + self.inner.lock().insert( + CacheKey::from(addr), + CachedToken { + credentials: Credentials { + username: None, + secret: token, + }, + expires_at: None, + 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 +229,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, refresh_at) = fetcher(addr.clone()).await?; + self.set_with_refresh_at(addr, token.clone(), 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. @@ -322,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 { @@ -330,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)), }, ); @@ -344,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 { @@ -352,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)), }, ); @@ -364,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 { @@ -372,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)), }, ); @@ -402,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(); @@ -426,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 87606e82e..ff7444c93 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -280,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? } }