From 42ccdbd4dd359567d3c4caa9352eee036142b523 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Mon, 20 Jul 2026 10:20:49 +0200 Subject: [PATCH 1/5] feat: add config surface for authentication plugins Add the AuthType::Plugin variant (auth_type = "plugin"), general.background_workers to bound concurrent auth-plugin calls, and users.server_role for backend role impersonation. --- example.pgdog.toml | 13 +++++++ example.users.toml | 16 +++++++++ pgdog-config/src/auth.rs | 67 +++++++++++++++++++++++++++++++++++++ pgdog-config/src/general.rs | 46 +++++++++++++++++++++++++ pgdog-config/src/users.rs | 42 +++++++++++++++++++++++ 5 files changed, 184 insertions(+) diff --git a/example.pgdog.toml b/example.pgdog.toml index cc648c890..b9e670c85 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -26,6 +26,12 @@ port = 6432 # Default: 2 # Recommended: 2 per CPU workers = 2 +# Maximum number of authentication-plugin calls allowed to run concurrently. +# Auth plugins run on a blocking thread pool; this caps how many run at once. +# Only relevant when auth_type = "plugin". +# +# Default: 4 +background_workers = 4 # Maximum number of Postgres connections per user/database connection pool. # # Default: 10 @@ -265,6 +271,13 @@ mirror_queue = 128 # - scram # - md5 # - trust +# - plain +# - plugin: delegate client authentication to a loaded plugin exposing the +# `pgdog_authenticate` hook (see the [[plugins]] section). When set, every +# plugin returning Skip results in a denied login (there is no password +# fallback), so a capable plugin must be loaded. Use `background_workers` +# to cap concurrent plugin calls. +# auth_type = "plugin" auth_type = "scram" # Disable cross-shard queries. # diff --git a/example.users.toml b/example.users.toml index 7b586f25e..8d8dd8a8e 100644 --- a/example.users.toml +++ b/example.users.toml @@ -43,3 +43,19 @@ password = "pgdog" # server_user = "pgdog_service" # server_auth = "vault_static" # server_vault_path = "database/static-creds/pgdog-service" + +# Example: role impersonation with plugin authentication (`auth_type = "plugin"` +# in pgdog.toml). Backend connections for this user assume `server_role` via the +# `role` startup parameter, and `SET ROLE` / `RESET ROLE` / `SET SESSION +# AUTHORIZATION` are rejected on the resulting pools. +# +# NOTE: the client no longer supplies the Postgres password (the plugin derives +# the login), so a working backend credential MUST be provided here via +# `server_password` (or a non-default `server_auth`). Without one, PgDog cannot +# open server connections and warns at config load time. +# [[users]] +# name = "analytics" +# database = "pgdog" +# server_role = "analytics_ro" +# server_user = "pgdog" +# server_password = "pgdog" diff --git a/pgdog-config/src/auth.rs b/pgdog-config/src/auth.rs index 0f76962b8..88887e91e 100644 --- a/pgdog-config/src/auth.rs +++ b/pgdog-config/src/auth.rs @@ -49,6 +49,8 @@ pub enum AuthType { Trust, /// Plaintext password. Plain, + /// Delegate client authentication to a loaded plugin exposing the `pgdog_authenticate` hook. + Plugin, } impl Display for AuthType { @@ -58,6 +60,7 @@ impl Display for AuthType { Self::Scram => write!(f, "scram"), Self::Trust => write!(f, "trust"), Self::Plain => write!(f, "plain"), + Self::Plugin => write!(f, "plugin"), } } } @@ -74,6 +77,10 @@ impl AuthType { pub fn trust(&self) -> bool { matches!(self, Self::Trust) } + + pub fn plugin(&self) -> bool { + matches!(self, Self::Plugin) + } } impl FromStr for AuthType { @@ -85,7 +92,67 @@ impl FromStr for AuthType { "scram" => Ok(Self::Scram), "trust" => Ok(Self::Trust), "plain" => Ok(Self::Plain), + "plugin" => Ok(Self::Plugin), _ => Err(format!("Invalid auth type: {}", s)), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_type_default_is_scram() { + assert_eq!(AuthType::default(), AuthType::Scram); + } + + #[test] + fn test_auth_type_display() { + assert_eq!(AuthType::Md5.to_string(), "md5"); + assert_eq!(AuthType::Scram.to_string(), "scram"); + assert_eq!(AuthType::Trust.to_string(), "trust"); + assert_eq!(AuthType::Plain.to_string(), "plain"); + assert_eq!(AuthType::Plugin.to_string(), "plugin"); + } + + #[test] + fn test_auth_type_from_str() { + assert_eq!("md5".parse::().unwrap(), AuthType::Md5); + assert_eq!("scram".parse::().unwrap(), AuthType::Scram); + assert_eq!("trust".parse::().unwrap(), AuthType::Trust); + assert_eq!("plain".parse::().unwrap(), AuthType::Plain); + assert_eq!("plugin".parse::().unwrap(), AuthType::Plugin); + // Case-insensitive. + assert_eq!("PLUGIN".parse::().unwrap(), AuthType::Plugin); + assert!("nonsense".parse::().is_err()); + } + + #[test] + fn test_auth_type_predicates() { + assert!(AuthType::Plugin.plugin()); + assert!(!AuthType::Scram.plugin()); + assert!(!AuthType::Plugin.scram()); + assert!(!AuthType::Plugin.md5()); + assert!(!AuthType::Plugin.trust()); + } + + #[test] + fn test_auth_type_serde_round_trip() { + // serde uses snake_case renaming; "plugin" is a single lowercase word. + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Wrapper { + auth_type: AuthType, + } + + let source = r#"auth_type = "plugin""#; + let parsed: Wrapper = toml::from_str(source).unwrap(); + assert_eq!(parsed.auth_type, AuthType::Plugin); + + let serialized = toml::to_string(&parsed).unwrap(); + assert!(serialized.contains(r#"auth_type = "plugin""#)); + + let round_tripped: Wrapper = toml::from_str(&serialized).unwrap(); + assert_eq!(round_tripped, parsed); + } +} diff --git a/pgdog-config/src/general.rs b/pgdog-config/src/general.rs index ee0c8d307..19c2e0f35 100644 --- a/pgdog-config/src/general.rs +++ b/pgdog-config/src/general.rs @@ -105,6 +105,18 @@ pub struct General { #[serde(default = "General::workers")] pub workers: usize, + /// Maximum number of authentication-plugin calls allowed to run concurrently. + /// Authentication plugins run on a blocking thread pool (auth happens once per + /// connection and may block on I/O); this bounds how many run at the same time. + /// + /// **Note:** Only relevant when `auth_type = "plugin"`. + /// + /// _Default:_ `4` + /// + /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#background_workers + #[serde(default = "General::background_workers")] + pub background_workers: usize, + /// Default maximum number of server connections per database pool. /// /// **Note:** We strongly recommend keeping this value well below the supported connections of the backend database(s) to allow connections for maintenance in high load scenarios. @@ -814,6 +826,7 @@ impl Default for General { host: Self::host(), port: Self::port(), workers: Self::workers(), + background_workers: Self::background_workers(), default_pool_size: Self::default_pool_size(), min_pool_size: Self::min_pool_size(), pooler_mode: Self::pooler_mode(), @@ -966,6 +979,10 @@ impl General { Self::env_or_default("PGDOG_WORKERS", 2) } + fn background_workers() -> usize { + Self::env_or_default("PGDOG_BACKGROUND_WORKERS", 4) + } + fn default_pool_size() -> usize { Self::env_or_default("PGDOG_DEFAULT_POOL_SIZE", 10) } @@ -1501,6 +1518,35 @@ mod tests { assert_eq!(General::workers(), 2); } + #[test] + fn test_env_background_workers() { + let _guard = set_env_var("PGDOG_BACKGROUND_WORKERS", "16"); + assert_eq!(General::background_workers(), 16); + let _guard = remove_env_var("PGDOG_BACKGROUND_WORKERS"); + assert_eq!(General::background_workers(), 4); + } + + #[test] + fn test_background_workers_default() { + let _guard = remove_env_var("PGDOG_BACKGROUND_WORKERS"); + assert_eq!(General::default().background_workers, 4); + } + + #[test] + fn test_background_workers_serde_round_trip() { + let _guard = remove_env_var("PGDOG_BACKGROUND_WORKERS"); + + // Omitted: falls back to the default. + let general: General = toml::from_str("").unwrap(); + assert_eq!(general.background_workers, 4); + + // Explicit value is parsed and re-serialized. + let general: General = toml::from_str("background_workers = 8").unwrap(); + assert_eq!(general.background_workers, 8); + let serialized = toml::to_string(&general).unwrap(); + assert!(serialized.contains("background_workers = 8")); + } + #[test] fn test_env_pool_sizes() { let _guard = set_env_var("PGDOG_DEFAULT_POOL_SIZE", "50"); diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 78a14202c..3b019500c 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -312,6 +312,14 @@ pub struct User { /// /// https://docs.pgdog.dev/configuration/users.toml/users/#server_password pub server_password: Option, + /// PostgreSQL role this user's backend connections assume via the `role` startup + /// parameter, used for impersonation with `auth_type = "plugin"`. + /// + /// **Note:** The user config no longer supplies the Postgres password when a plugin + /// derives the login, so a working backend credential must be provided via + /// `server_password` or a non-default `server_auth`. `SET ROLE`, `RESET ROLE`, and + /// `SET SESSION AUTHORIZATION` are rejected on pools that set this. + pub server_role: Option, /// Backend auth mode for server connections. #[serde(default)] pub server_auth: ServerAuth, @@ -792,6 +800,40 @@ vault_refresh_percent = 60 assert!(passwords.iter().any(|p| matches!(p, PasswordKind::VaultStaticRole(s) if s == "database/static-creds/alice-role"))); } + #[test] + fn test_user_server_role_defaults_to_none() { + let source = r#" +[[users]] +name = "alice" +database = "db" +password = "secret" +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert!(user.server_role.is_none()); + } + + #[test] + fn test_user_server_role_round_trip() { + let source = r#" +[[users]] +name = "alice" +database = "db" +server_role = "analytics" +server_user = "svc" +server_password = "svc_secret" +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert_eq!(user.server_role.as_deref(), Some("analytics")); + + let serialized = toml::to_string(users.users.first().unwrap()).unwrap(); + let reparsed: User = toml::from_str(&serialized).unwrap(); + assert_eq!(reparsed.server_role.as_deref(), Some("analytics")); + } + #[test] fn test_vault_static_is_external_identity() { assert!(ServerAuth::VaultStatic.is_external_identity()); From d0e63430b9933dd826f740fdea3a202facf8ed4c Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Mon, 20 Jul 2026 10:27:59 +0200 Subject: [PATCH 2/5] feat: add authentication hook to the plugin ABI Extend the plugin vtable with an authenticate entry and a corresponding Plugin::authenticate trait method (default: Skip). The plugin returns an owned AuthDecision; the generated bridge streams each grant/deny string to a host callback as a borrowed PdStr and returns POD by value, so no ownership crosses the FFI boundary. Bumps pgdog-plugin to 0.5.0. --- Cargo.lock | 2 +- Cargo.toml | 2 +- pgdog-plugin/Cargo.toml | 2 +- pgdog-plugin/src/auth.rs | 140 ++++++++++++++++++++++++++++++++++++ pgdog-plugin/src/lib.rs | 2 + pgdog-plugin/src/plugin.rs | 94 ++++++++++++++++++++++++ pgdog-plugin/src/prelude.rs | 1 + 7 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 pgdog-plugin/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index afd91087f..6c575b80e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3463,7 +3463,7 @@ dependencies = [ [[package]] name = "pgdog-plugin" -version = "0.4.0" +version = "0.5.0" dependencies = [ "bindgen 0.71.1", "libloading", diff --git a/Cargo.toml b/Cargo.toml index 4ef0e72f3..49e4279be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ edition = "2024" [workspace.dependencies] -pgdog-plugin = { path = "./pgdog-plugin", version = "0.4.0", default-features = false } +pgdog-plugin = { path = "./pgdog-plugin", version = "0.5.0", default-features = false } pgdog-config = { path = "./pgdog-config", version = "0.1.0" } pgdog-postgres-types = { path = "./pgdog-postgres-types"} pg_raw_parse = { git = "https://github.com/pgdogdev/pg_raw_parse.git", rev = "457b7c9" } diff --git a/pgdog-plugin/Cargo.toml b/pgdog-plugin/Cargo.toml index 7b2ef3a16..380fd8339 100644 --- a/pgdog-plugin/Cargo.toml +++ b/pgdog-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pgdog-plugin" -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT" authors = ["Lev Kokotov "] diff --git a/pgdog-plugin/src/auth.rs b/pgdog-plugin/src/auth.rs new file mode 100644 index 000000000..fda4569a1 --- /dev/null +++ b/pgdog-plugin/src/auth.rs @@ -0,0 +1,140 @@ +//! Client authentication hook. +//! +//! Plugins can validate the credential a client presents at login (a password, +//! a JWT, an API key, ...) and, on success, derive the Postgres role the +//! session should run as and how its pool should connect to the backend. +//! +//! # No ownership crosses the FFI boundary +//! +//! [`AuthDecision`] and [`AuthGrant`] are ordinary owned Rust values used by +//! plugin authors. They never cross FFI. The generated bridge keeps the owned +//! decision alive on the plugin's stack and streams each string field to the +//! host as a borrowed [`PdStr`] through the [`AuthSink`] callback, returning +//! only the POD [`AuthOutcome`] by value. This mirrors how [`crate::Config`] +//! hands borrowed strings the other way. + +use crate::PdStr; +use std::ffi::c_void; + +/// Context for a single client authentication attempt. +/// +/// All strings are borrowed and only valid for the duration of the +/// [`Plugin::authenticate`](crate::Plugin::authenticate) call. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct AuthContext<'a> { + /// User from the startup packet. + pub user: PdStr<'a>, + /// Database from the startup packet. + pub database: PdStr<'a>, + /// Credential the client presented (password, JWT, token, ...). + pub credential: PdStr<'a>, + /// Client socket address, e.g. `"10.0.0.1:54321"`. + pub client_addr: PdStr<'a>, + /// TLS certificate identity; empty when the connection has none. + pub tls_identity: PdStr<'a>, + /// Whether the connection uses TLS. + pub tls: bool, +} + +/// Backend and pool details returned when a plugin authenticates a client. +/// +/// A plain owned Rust value; it never crosses the FFI boundary. +#[derive(Debug, Default, Clone)] +pub struct AuthGrant { + /// Postgres role the pool runs as; `None` keeps the startup-packet user. + pub derived_user: Option, + /// Role assumed on the backend via the `role` startup parameter. + pub server_role: Option, + /// Backend user for an auto-provisioned pool. + pub server_user: Option, + /// Backend password for an auto-provisioned pool. + pub server_password: Option, + /// Whether the provisioned pool is read-only. `None` leaves it unset. + pub read_only: Option, + /// Auto-provision a pool for `derived_user` when one does not exist. + pub provision: bool, +} + +/// A plugin's verdict on a client authentication attempt. +#[derive(Debug, Clone)] +pub enum AuthDecision { + /// Not this plugin's credential; consult the next plugin. + Skip, + /// Authenticated; optionally derive a role and provision a pool. + Allow(AuthGrant), + /// Rejected. The reason is logged by PgDog, never sent to the client. + Deny(String), +} + +/// FFI tag for an [`AuthDecision`], returned by value across the boundary. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AuthDecisionTag { + /// Defer to the next plugin. + Skip = 0, + /// Client authenticated. + Allow = 1, + /// Client rejected. + Deny = 2, +} + +/// Which [`AuthGrant`] field (or deny reason) a plugin is reporting through the +/// [`AuthSink`] callback. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AuthField { + /// [`AuthGrant::derived_user`]. + DerivedUser = 0, + /// [`AuthGrant::server_role`]. + ServerRole = 1, + /// [`AuthGrant::server_user`]. + ServerUser = 2, + /// [`AuthGrant::server_password`]. + ServerPassword = 3, + /// [`AuthDecision::Deny`] reason. + Error = 4, +} + +/// POD result of an FFI authenticate call. +/// +/// String fields are not carried here; they are streamed to the host through +/// the [`AuthSink`] while the plugin-owned [`AuthDecision`] is still alive, so +/// no ownership crosses FFI. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct AuthOutcome { + /// The decision kind. + pub tag: AuthDecisionTag, + /// `0` = read-write, `1` = read-only, `2` = unset. + pub read_only: u8, + /// Auto-provision the derived user's pool. + pub provision: bool, +} + +impl AuthOutcome { + /// The neutral "defer to the next plugin" outcome. + pub(crate) const fn skip() -> Self { + Self { + tag: AuthDecisionTag::Skip, + read_only: 2, + provision: false, + } + } +} + +/// Encode an `Option` read-only flag as its [`AuthOutcome::read_only`] code. +pub(crate) fn read_only_code(value: Option) -> u8 { + match value { + None => 2, + Some(false) => 0, + Some(true) => 1, + } +} + +/// Callback the plugin invokes to hand a borrowed field value to the host. +/// +/// The first argument is an opaque host pointer passed straight back; the host +/// side reconstructs its closure from it. Only ever called synchronously from +/// within the authenticate call, on the same thread. +pub type AuthSink = extern "C-unwind" fn(*mut c_void, AuthField, PdStr<'_>); diff --git a/pgdog-plugin/src/lib.rs b/pgdog-plugin/src/lib.rs index 79dd2081c..3ba38229c 100644 --- a/pgdog-plugin/src/lib.rs +++ b/pgdog-plugin/src/lib.rs @@ -195,6 +195,7 @@ compile_error!( r#"pg-plugin must be built with either default features, or features = "new_parser""# ); +pub mod auth; mod config; pub mod context; pub mod logging; @@ -204,6 +205,7 @@ pub mod plugin; pub mod prelude; pub mod string; +pub use auth::*; pub use config::Config; pub use context::*; pub use parameters::*; diff --git a/pgdog-plugin/src/plugin.rs b/pgdog-plugin/src/plugin.rs index 0432073ff..ce9491dbc 100644 --- a/pgdog-plugin/src/plugin.rs +++ b/pgdog-plugin/src/plugin.rs @@ -4,10 +4,12 @@ //! a safe interface to the plugin's methods. //! +use std::ffi::c_void; use std::path::Path; use crate::{ Config, Context, PdStr, Route, + auth::{AuthContext, AuthDecision, AuthField, AuthOutcome, AuthSink, read_only_code}, parameters::{Parameters, RawParameters}, }; use libloading::{Library, Symbol, library_filename}; @@ -59,6 +61,9 @@ pub struct PluginVtable { ) -> Route, /// Logging initialization. logging_init: extern "C-unwind" fn(Config<'_>), + /// Authenticate a client connection. Streams grant/deny strings to the + /// sink callback and returns the decision as POD. + authenticate: extern "C-unwind" fn(AuthContext<'_>, *mut c_void, AuthSink) -> AuthOutcome, } pub trait Plugin { @@ -127,6 +132,67 @@ pub trait Plugin { extern "C-unwind" fn logging_init(config: Config<'_>) { crate::logging::init(config) } + + /// Authenticate a client connection. + /// + /// Return [`AuthDecision::Skip`] (the default) to defer to the next plugin + /// or to PgDog's configured authentication. [`AuthDecision::Allow`] accepts + /// the client and may derive a role and provision a pool; + /// [`AuthDecision::Deny`] rejects it (the reason is logged, never sent to + /// the client). + fn authenticate(_context: AuthContext<'_>) -> AuthDecision { + AuthDecision::Skip + } + + #[doc(hidden)] + extern "C-unwind" fn authenticate_raw( + context: AuthContext<'_>, + sink_ctx: *mut c_void, + sink: AuthSink, + ) -> AuthOutcome { + // Catch a panic here, inside the plugin, before it can unwind across + // the FFI boundary. A panic that crosses `extern "C-unwind"` becomes a + // "foreign exception" the host cannot catch, which aborts the whole + // process; catching it here turns a buggy auth plugin into a denial + // instead. `AuthContext` is Copy, so the closure captures it by value. + let decision = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::authenticate(context))) + .unwrap_or_else(|_| AuthDecision::Deny("authentication plugin panicked".into())); + + // The owned decision lives here, on the plugin's stack, for the whole + // call. We stream each field to the host as a borrowed PdStr; nothing + // owned crosses the FFI boundary. + match decision { + AuthDecision::Skip => AuthOutcome::skip(), + AuthDecision::Deny(reason) => { + sink(sink_ctx, AuthField::Error, PdStr::from(reason.as_str())); + AuthOutcome { + tag: crate::auth::AuthDecisionTag::Deny, + read_only: 2, + provision: false, + } + } + AuthDecision::Allow(grant) => { + if let Some(value) = grant.derived_user.as_deref() { + sink(sink_ctx, AuthField::DerivedUser, PdStr::from(value)); + } + if let Some(value) = grant.server_role.as_deref() { + sink(sink_ctx, AuthField::ServerRole, PdStr::from(value)); + } + if let Some(value) = grant.server_user.as_deref() { + sink(sink_ctx, AuthField::ServerUser, PdStr::from(value)); + } + if let Some(value) = grant.server_password.as_deref() { + sink(sink_ctx, AuthField::ServerPassword, PdStr::from(value)); + } + AuthOutcome { + tag: crate::auth::AuthDecisionTag::Allow, + read_only: read_only_code(grant.read_only), + provision: grant.provision, + } + } + } + } } impl PluginVtable { @@ -141,6 +207,7 @@ impl PluginVtable { plugin_version: T::version, pgdog_plugin_api_version: T::plugin_api_version, logging_init: T::logging_init, + authenticate: T::authenticate_raw, } } @@ -220,4 +287,31 @@ impl PluginVtable { pub fn logging_init(&self, config: Config<'_>) { (self.logging_init)(config) } + + /// Authenticate a client. `on_field` is invoked with each grant/deny string + /// the plugin reports (borrowed for the duration of the call); the caller + /// copies what it needs into owned storage. + pub fn authenticate( + &self, + context: AuthContext<'_>, + mut on_field: F, + ) -> AuthOutcome { + extern "C-unwind" fn trampoline( + ctx: *mut c_void, + field: AuthField, + value: PdStr<'_>, + ) { + // SAFETY: `ctx` is the `&mut F` passed below. The plugin only calls + // this synchronously, on this thread, before `authenticate` + // returns, so the borrow is live and unaliased. + let on_field = unsafe { &mut *(ctx as *mut F) }; + on_field(field, &value); + } + + (self.authenticate)( + context, + &mut on_field as *mut F as *mut c_void, + trampoline::, + ) + } } diff --git a/pgdog-plugin/src/prelude.rs b/pgdog-plugin/src/prelude.rs index ecfb4282b..052194c82 100644 --- a/pgdog-plugin/src/prelude.rs +++ b/pgdog-plugin/src/prelude.rs @@ -4,5 +4,6 @@ pub use crate::pg_query; pub use crate::{ Context, ParameterFormat, PdStr, Plugin, ReadWrite, Route, Shard, + auth::{AuthContext, AuthDecision, AuthGrant}, parameters::{Parameter, ParameterValue, Parameters}, }; From df3b24b32335daaf99a6f8a97d8444a9cc7f0a60 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Mon, 20 Jul 2026 10:49:41 +0200 Subject: [PATCH 3/5] feat: wire plugin authentication into the client login path Drive the cleartext exchange for auth_type = "plugin", consult the plugins via a spawn_blocking driver capped by background_workers, and on Allow thread the derived user through the backend path and provision its pool with databases::add_authenticated (no password comparison). Add server_role impersonation: the role is sent as a startup parameter (a session default that survives RESET ALL / DISCARD ALL), and the query parser rejects client SET ROLE / RESET ROLE / SET SESSION AUTHORIZATION / set_config('role', ...) on such pools with a 42501 error. Gate pool launch on per-cluster backend credentials so impersonation and plugin-auth pools without a stored client password still start. --- pgdog/src/auth/auth_result.rs | 7 + pgdog/src/auth/mod.rs | 1 + pgdog/src/auth/plugin.rs | 269 ++++++++++++++++++ .../backend/auth/azure_workload_identity.rs | 1 + pgdog/src/backend/auth/rds_iam.rs | 1 + pgdog/src/backend/auth/vault.rs | 1 + pgdog/src/backend/databases.rs | 72 ++++- pgdog/src/backend/pool/address.rs | 6 + pgdog/src/backend/pool/cluster.rs | 45 +++ pgdog/src/backend/pool/pool_impl.rs | 10 + pgdog/src/frontend/client/mod.rs | 82 +++++- pgdog/src/frontend/client/query_engine/mod.rs | 6 + pgdog/src/frontend/router/parser/command.rs | 7 + pgdog/src/frontend/router/parser/query/mod.rs | 20 ++ .../router/parser/query/set_config.rs | 75 +++++ pgdog/src/net/messages/error_response.rs | 13 + 16 files changed, 609 insertions(+), 7 deletions(-) create mode 100644 pgdog/src/auth/plugin.rs diff --git a/pgdog/src/auth/auth_result.rs b/pgdog/src/auth/auth_result.rs index 7f83e1578..d8295308e 100644 --- a/pgdog/src/auth/auth_result.rs +++ b/pgdog/src/auth/auth_result.rs @@ -19,6 +19,11 @@ pub enum AuthResult { NoUserOrDatabase, /// Client didn't provide password message. NoPasswordMessage, + /// An authentication plugin explicitly denied the client. + PluginDenied, + /// No authentication plugin made a decision (all skipped). Treated as a + /// denial: `auth_type = "plugin"` is explicit, there is no password fallback. + PluginNoDecision, } impl AuthResult { @@ -46,6 +51,8 @@ impl Display for AuthResult { } Self::NoUserOrDatabase => write!(f, "no user or database in config"), Self::NoPasswordMessage => write!(f, "client did not send password message"), + Self::PluginDenied => write!(f, "authentication plugin denied the client"), + Self::PluginNoDecision => write!(f, "no authentication plugin accepted the client"), } } } diff --git a/pgdog/src/auth/mod.rs b/pgdog/src/auth/mod.rs index da07a343f..8a84d5b70 100644 --- a/pgdog/src/auth/mod.rs +++ b/pgdog/src/auth/mod.rs @@ -3,6 +3,7 @@ pub mod auth_result; pub mod error; pub mod md5; +pub mod plugin; pub mod scram; pub mod vault; diff --git a/pgdog/src/auth/plugin.rs b/pgdog/src/auth/plugin.rs new file mode 100644 index 000000000..4ce9f41be --- /dev/null +++ b/pgdog/src/auth/plugin.rs @@ -0,0 +1,269 @@ +//! Authentication plugin driver. +//! +//! When `auth_type = "plugin"`, PgDog drives the client wire exchange (asking +//! for a cleartext password) and hands the credential to the loaded plugins. +//! Each plugin answers with an [`AuthDecision`]; the first plugin that does not +//! [`Skip`](pgdog_plugin::AuthDecision::Skip) wins. If every plugin skips, PgDog +//! denies the client: `auth_type = "plugin"` is explicit and there is no +//! fallback to password verification (maintainer decision). +//! +//! Plugins run inside a single [`tokio::task::spawn_blocking`] call (they may +//! block on I/O), and the number of concurrent authentication calls is capped by +//! a [`Semaphore`] sized from the `background_workers` setting. + +use std::sync::OnceLock; + +use pgdog_plugin::{AuthContext, AuthDecisionTag, AuthField, AuthGrant, PdStr}; +use tokio::sync::Semaphore; +use tracing::{debug, warn}; + +use crate::auth::AuthResult; +use crate::config::config; +use crate::plugin::plugins; + +/// Outcome of running the authentication plugins for a single client. +#[derive(Debug)] +pub struct PluginAuthOutcome { + /// Overall result: [`AuthResult::Ok`] on Allow, otherwise a plugin denial. + pub result: AuthResult, + /// Username the plugin derived for the client, if any. + pub derived_user: Option, + /// Grant returned by the accepting plugin (only set on Allow). + pub grant: Option, + /// Name of the plugin that made the decision, for logging. + pub plugin_name: Option, +} + +impl PluginAuthOutcome { + fn allow(grant: AuthGrant, plugin_name: String) -> Self { + Self { + result: AuthResult::Ok, + derived_user: grant.derived_user.clone(), + grant: Some(grant), + plugin_name: Some(plugin_name), + } + } + + fn denied(plugin_name: String) -> Self { + Self { + result: AuthResult::PluginDenied, + derived_user: None, + grant: None, + plugin_name: Some(plugin_name), + } + } + + fn no_decision() -> Self { + Self { + result: AuthResult::PluginNoDecision, + derived_user: None, + grant: None, + plugin_name: None, + } + } +} + +/// Cap on concurrent authentication-plugin calls. Sized once from +/// `background_workers`; plugins run on the blocking pool so this keeps a burst +/// of logins from exhausting it. +static SEMAPHORE: OnceLock = OnceLock::new(); + +fn semaphore() -> &'static Semaphore { + SEMAPHORE.get_or_init(|| { + let permits = config().config.general.background_workers.max(1); + Semaphore::new(permits) + }) +} + +/// Authenticate a client through the loaded authentication plugins. +/// +/// The owned strings are moved into a single `spawn_blocking` call that builds +/// the [`AuthContext`] (whose [`PdStr`] fields borrow them) and consults the +/// plugins. A task join failure is treated as a denial. +pub async fn authenticate( + user: String, + database: String, + credential: String, + client_addr: String, + tls_identity: Option, + tls: bool, +) -> PluginAuthOutcome { + let permit = match semaphore().acquire().await { + Ok(permit) => permit, + Err(_) => { + // The semaphore is never closed; this only happens on shutdown. + warn!("authentication plugin semaphore closed, denying client"); + return PluginAuthOutcome::no_decision(); + } + }; + + let join = tokio::task::spawn_blocking(move || { + // Hold the permit until the blocking work is done. + let _permit = permit; + run( + &user, + &database, + &credential, + &client_addr, + tls_identity, + tls, + ) + }) + .await; + + match join { + Ok(outcome) => outcome, + Err(err) => { + warn!("authentication plugin task failed: {}", err); + PluginAuthOutcome::no_decision() + } + } +} + +/// Fields a plugin streamed back through the authenticate callback. +#[derive(Default)] +struct Collected { + derived_user: Option, + server_role: Option, + server_user: Option, + server_password: Option, + error: Option, +} + +/// Consult the plugins. First non-Skip decision wins. +/// +/// Plugins that do not implement `authenticate` return Skip via the trait +/// default, so no capability check is needed. Iteration order follows the +/// plugin registry (a map), matching how routing consults plugins. +fn run( + user: &str, + database: &str, + credential: &str, + client_addr: &str, + tls_identity: Option, + tls: bool, +) -> PluginAuthOutcome { + let Some(plugins) = plugins() else { + return PluginAuthOutcome::no_decision(); + }; + + // Empty identity == absent, matching the PdStr borrow convention. + let tls_identity = tls_identity.unwrap_or_default(); + + // AuthContext is Copy and only borrows these strings, which outlive the loop. + let context = AuthContext { + user: PdStr::from(user), + database: PdStr::from(database), + credential: PdStr::from(credential), + client_addr: PdStr::from(client_addr), + tls_identity: PdStr::from(tls_identity.as_str()), + tls, + }; + + for (name, plugin) in plugins { + let mut collected = Collected::default(); + let outcome = plugin.authenticate(context, |field, value| { + let slot = match field { + AuthField::DerivedUser => &mut collected.derived_user, + AuthField::ServerRole => &mut collected.server_role, + AuthField::ServerUser => &mut collected.server_user, + AuthField::ServerPassword => &mut collected.server_password, + AuthField::Error => &mut collected.error, + }; + *slot = Some(value.to_owned()); + }); + + match outcome.tag { + AuthDecisionTag::Skip => continue, + AuthDecisionTag::Allow => { + let grant = AuthGrant { + derived_user: collected.derived_user, + server_role: collected.server_role, + server_user: collected.server_user, + server_password: collected.server_password, + read_only: match outcome.read_only { + 0 => Some(false), + 1 => Some(true), + _ => None, + }, + provision: outcome.provision, + }; + debug!(r#"client "{}" authenticated by plugin "{}""#, user, name); + return PluginAuthOutcome::allow(grant, name.clone()); + } + AuthDecisionTag::Deny => { + // The reason is logged but never sent to the client. + warn!( + r#"client "{}" denied by plugin "{}": {}"#, + user, + name, + collected.error.as_deref().unwrap_or("no reason given"), + ); + return PluginAuthOutcome::denied(name.clone()); + } + } + } + + // Every plugin skipped (or there were none). Deny: no password fallback. + PluginAuthOutcome::no_decision() +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn test_all_skip_is_no_decision() { + // With no plugins loaded, the driver denies via PluginNoDecision. + let outcome = authenticate( + "alice".into(), + "pgdog".into(), + "secret".into(), + "127.0.0.1:5432".into(), + None, + false, + ) + .await; + + assert_eq!(outcome.result, AuthResult::PluginNoDecision); + assert!(outcome.grant.is_none()); + assert!(outcome.derived_user.is_none()); + assert!(outcome.plugin_name.is_none()); + assert!(!outcome.result.is_ok()); + } + + #[test] + fn test_run_no_plugins_denies() { + let outcome = run("bob", "pgdog", "secret", "127.0.0.1:5432", None, false); + assert_eq!(outcome.result, AuthResult::PluginNoDecision); + } + + #[tokio::test] + async fn test_semaphore_sized_from_config() { + // The semaphore is created lazily and bounded (>= 1 permit). Acquiring + // and releasing a permit must succeed. + let permit = semaphore().acquire().await; + assert!(permit.is_ok()); + assert!(semaphore().available_permits() >= 1); + } + + #[test] + fn test_outcome_constructors() { + let grant = AuthGrant { + derived_user: Some("reporting".into()), + provision: true, + ..Default::default() + }; + let allow = PluginAuthOutcome::allow(grant, "jwt".into()); + assert!(allow.result.is_ok()); + assert_eq!(allow.derived_user.as_deref(), Some("reporting")); + assert_eq!(allow.plugin_name.as_deref(), Some("jwt")); + + let denied = PluginAuthOutcome::denied("jwt".into()); + assert_eq!(denied.result, AuthResult::PluginDenied); + assert!(!denied.result.is_ok()); + + let none = PluginAuthOutcome::no_decision(); + assert_eq!(none.result, AuthResult::PluginNoDecision); + } +} diff --git a/pgdog/src/backend/auth/azure_workload_identity.rs b/pgdog/src/backend/auth/azure_workload_identity.rs index 6346b2123..469170638 100644 --- a/pgdog/src/backend/auth/azure_workload_identity.rs +++ b/pgdog/src/backend/auth/azure_workload_identity.rs @@ -59,6 +59,7 @@ mod tests { passwords: vec![], database_number: 0, server_auth: ServerAuth::AzureWorkloadIdentity, + server_role: None, server_iam_region: None, vault_path: Default::default(), vault_refresh_percent: None, diff --git a/pgdog/src/backend/auth/rds_iam.rs b/pgdog/src/backend/auth/rds_iam.rs index 8e3d3aa76..fa2bc627b 100644 --- a/pgdog/src/backend/auth/rds_iam.rs +++ b/pgdog/src/backend/auth/rds_iam.rs @@ -100,6 +100,7 @@ mod tests { passwords: vec![], database_number: 0, server_auth: ServerAuth::RdsIam, + server_role: None, server_iam_region: Some("us-east-1".into()), vault_path: Default::default(), vault_refresh_percent: None, diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 297a74af0..5c8d47ddd 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -185,6 +185,7 @@ mod tests { user: "testuser".into(), passwords: vec![], server_auth: Default::default(), + server_role: None, server_iam_region: None, vault_path: vault_path.map(Into::into), vault_refresh_percent: None, diff --git a/pgdog/src/backend/databases.rs b/pgdog/src/backend/databases.rs index f3d6b762b..7e4c91eb1 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -213,6 +213,66 @@ pub(crate) fn add(user: ConfigUser) -> Result { } } +/// Provision or complete a user during plugin authentication. +/// +/// Unlike [`add`], this does not compare a client password: the plugin has +/// already authenticated the client, and the credential (e.g. a JWT) differs +/// on every login. A configured `users.toml` entry is never overwritten — +/// only missing backend credentials are filled in. +pub fn add_authenticated(user: ConfigUser) -> Result { + fn store(user: ConfigUser) -> Result<(), Error> { + let _lock = lock(); + let mut config = (*config()).clone(); + config.users.add_or_replace(user); + set(config)?; + Ok(()) + } + + let config = config(); + let existing = config.users.find(&user); + + if let Some(mut existing) = existing { + // Never overwrite a configured entry; only fill gaps so a partially + // configured user still gets usable backend credentials. + let mut changed = false; + if existing.server_user.is_none() && user.server_user.is_some() { + existing.server_user = user.server_user.clone(); + changed = true; + } + if existing.server_password.is_none() && user.server_password.is_some() { + existing.server_password = user.server_password.clone(); + changed = true; + } + if existing.server_role.is_none() && user.server_role.is_some() { + existing.server_role = user.server_role.clone(); + changed = true; + } + if existing.read_only.is_none() && user.read_only.is_some() { + existing.read_only = user.read_only; + changed = true; + } + + if changed { + debug!( + r#"filling backend-credential gaps for user "{}" on database "{}""#, + existing.name, existing.database + ); + store(existing)?; + reload_from_existing()?; + } + + Ok(AuthResult::Ok) + } else { + debug!( + r#"provisioning user "{}" on database "{}" via plugin authentication"#, + user.name, user.database + ); + store(user)?; + reload_from_existing()?; + Ok(AuthResult::Ok) + } +} + /// Swap database configs between source and destination. /// Both databases keep their names, but their configs (host, port, etc.) are exchanged. /// User database references are also swapped. @@ -461,7 +521,17 @@ impl Databases { // Launch all clusters for cluster in self.all().values() { - if cluster.passwords().is_empty() && cluster.identity().is_none() { + // A cluster is only useful if a client can authenticate to it and it + // can authenticate to Postgres. The empty client-password case is + // still allowed when the cluster carries its own backend credentials + // (server password, external identity, or an impersonation + // `server_role`): that is how plugin auth and auto-provisioned + // impersonation pools work — they authenticate each login via a + // plugin rather than a stored client password. + if cluster.passwords().is_empty() + && cluster.identity().is_none() + && !cluster.has_backend_credentials() + { warn!( r#"disabling pool for user "{}" and database "{}", password not set"#, cluster.user(), diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 9443a9069..48b9dda40 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -30,6 +30,10 @@ pub struct Address { /// Server auth mode for backend connections. #[serde(default)] pub server_auth: ServerAuth, + /// PostgreSQL role backend connections assume via the `role` startup + /// parameter, from `User.server_role` (plugin-auth impersonation). + #[serde(default)] + pub server_role: Option, /// Optional IAM region override. pub server_iam_region: Option, /// Vault path to fetch dynamic credentials from. @@ -94,6 +98,7 @@ impl Address { .collect() }, server_auth, + server_role: user.server_role.clone(), server_iam_region: user.server_iam_region.clone(), vault_path: user.server_vault_path.clone(), vault_refresh_percent: user.vault_refresh_percent, @@ -208,6 +213,7 @@ impl Address { passwords: vec!["pgdog".into()], database_name: "pgdog".into(), server_auth: ServerAuth::Password, + server_role: None, server_iam_region: None, vault_path: None, vault_refresh_percent: None, diff --git a/pgdog/src/backend/pool/cluster.rs b/pgdog/src/backend/pool/cluster.rs index 78f39af2e..98b297574 100644 --- a/pgdog/src/backend/pool/cluster.rs +++ b/pgdog/src/backend/pool/cluster.rs @@ -88,6 +88,7 @@ pub struct Cluster { resharding_replication_retry_min_delay: Duration, regex_parser: RegexParser, identity: Option, + server_role: Option, } /// Sharding configuration from the cluster. @@ -175,6 +176,7 @@ pub struct ClusterConfig<'a> { pub regex_parser_limit: usize, pub pub_sub_enabled: bool, pub identity: &'a Option, + pub server_role: Option, } impl<'a> ClusterConfig<'a> { @@ -239,6 +241,7 @@ impl<'a> ClusterConfig<'a> { regex_parser_limit: general.regex_parser_limit, pub_sub_enabled: general.pub_sub_enabled(), identity: &user.identity, + server_role: user.server_role.clone(), } } } @@ -285,6 +288,7 @@ impl Cluster { regex_parser_limit, pub_sub_enabled, identity, + server_role, } = config; let identifier = Arc::new(DatabaseUser { @@ -346,6 +350,7 @@ impl Cluster { ), regex_parser: RegexParser::new(regex_parser_limit, query_parser), identity: identity.clone(), + server_role, } } @@ -414,6 +419,33 @@ impl Cluster { self.identity.as_deref() } + /// PostgreSQL role backend connections impersonate via the `role` startup + /// parameter. When set, client-issued `SET ROLE` / `RESET ROLE` / + /// `SET SESSION AUTHORIZATION` are rejected to prevent role escape. + pub fn server_role(&self) -> Option<&str> { + self.server_role.as_deref() + } + + /// Whether this cluster carries backend credentials to authenticate to + /// Postgres on its own, independent of any stored client password: an + /// impersonation `server_role`, a server password, or an external-identity + /// mechanism (RDS IAM, Azure, Vault). Pools like these must launch even + /// when no client password is configured, e.g. under `auth_type = "plugin"` + /// where plugins authenticate each login. + pub fn has_backend_credentials(&self) -> bool { + if self.server_role.is_some() { + return true; + } + + self.shards + .iter() + .flat_map(|shard| shard.pools()) + .any(|pool| { + let addr = pool.addr(); + !addr.passwords.is_empty() || addr.server_auth.is_external_identity() + }) + } + /// User name. pub fn user(&self) -> &str { &self.identifier.user @@ -516,6 +548,15 @@ impl Cluster { /// Use the query parser. pub(crate) fn use_query_parser(&self, request: &ClientRequest) -> bool { + // Impersonation pools must parse every statement so the role-escape + // guard can inspect it. Otherwise the regex fast-path would forward + // e.g. `SELECT 1; SET ROLE ...` or `SELECT set_config('role', ...)` + // verbatim, escaping the fixed `server_role`. Normal pools are + // unaffected — this is a single `Option` check. + if self.server_role.is_some() { + return true; + } + match self.query_parser() { QueryParserLevel::Off => false, QueryParserLevel::On => true, @@ -989,6 +1030,10 @@ mod test { pub(crate) fn set_rw_split(&mut self, rw_split: ReadWriteSplit) { self.rw_split = rw_split; } + + pub fn set_server_role(&mut self, server_role: Option) { + self.server_role = server_role; + } } #[test] diff --git a/pgdog/src/backend/pool/pool_impl.rs b/pgdog/src/backend/pool/pool_impl.rs index 8ac5e97d5..15afa1b03 100644 --- a/pgdog/src/backend/pool/pool_impl.rs +++ b/pgdog/src/backend/pool/pool_impl.rs @@ -455,6 +455,16 @@ impl Pool { }); } + // Impersonation role from `User.server_role`. Sent as a startup + // parameter so it's a session default: `RESET ALL` / `DISCARD ALL` + // cleanup restores rather than clears it between checkouts. + if let Some(role) = &self.inner.addr.server_role { + params.push(Parameter { + name: "role".into(), + value: role.as_str().into(), + }); + } + ServerOptions { params, pool_id: self.id(), diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 56f9004ed..b424b9916 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -191,7 +191,10 @@ impl Client { } } - AuthType::Plain => { + // `Plugin` only reaches here on the admin path (plugin auth is + // driven separately in `login`), where it behaves like `Plain`: + // compare against the configured admin password. + AuthType::Plain | AuthType::Plugin => { stream .send_flush(&Authentication::ClearTextPassword) .await?; @@ -240,6 +243,10 @@ impl Client { let comms = ClientComms::new(id); let log_connections = config.config.general.log_connections; + // Username a plugin derived for the client (e.g. impersonation). When + // set, it replaces `user` for all backend operations. + let mut derived_user: Option = None; + // Check if we need to ask the client for its password in plaintext // because we don't actually have it configured. // @@ -250,6 +257,59 @@ impl Client { // map, so authenticate directly against the configured admin password. let passwords = [PasswordKind::Plain(admin_password.clone())]; Self::check_password(&mut stream, user, auth_type, &passwords).await? + } else if auth_type.plugin() { + // Plugin authentication: request a cleartext credential from the + // client (same wire flow as passthrough), then hand it to the + // authentication plugins. Allow can derive a user and provision a + // pool; Deny/all-Skip reject the client without a password fallback. + stream + .send_flush(&Authentication::ClearTextPassword) + .await?; + let password = stream.read().await?; + let password = Password::from_bytes(password.to_bytes())?; + if let Some(credential) = password.password() { + let tls_identity = stream.tls_identity().map(|id| id.to_string()); + let outcome = crate::auth::plugin::authenticate( + user.to_string(), + database.to_string(), + credential.to_string(), + addr.to_string(), + tls_identity, + stream.is_tls(), + ) + .await; + + if outcome.result.is_ok() { + if let Some(grant) = outcome.grant { + derived_user = grant.derived_user.clone(); + let effective = derived_user.as_deref().unwrap_or(user); + + // Auto-provision a pool for the derived user when the + // plugin asked for it and no cluster exists yet. + if grant.provision + && databases::databases() + .cluster((effective, database)) + .is_err() + { + let provisioned = config::User { + name: effective.to_string(), + database: database.to_string(), + server_user: grant.server_user.clone(), + server_password: grant.server_password.clone(), + server_role: grant.server_role.clone(), + read_only: grant.read_only, + ..Default::default() + }; + databases::add_authenticated(provisioned)?; + } + } + AuthResult::Ok + } else { + outcome.result + } + } else { + AuthResult::NoPasswordMessage + } } else if passthrough { // Get the password. We always need it because we need to check if // it's current and hasn't been changed. @@ -292,14 +352,22 @@ impl Client { } }; + // When a plugin derived a user, use that name for `Connection::new`, + // error responses, and log lines; otherwise use the startup username. + // Note that comms and stats keep using the startup-packet parameters + // (and thus the original startup user), matching PR #1084. + let effective_user = derived_user.as_deref().unwrap_or(user); + if !auth_result.is_ok() { if log_connections { warn!( r#"user "{}" and database "{}" auth error: {}"#, - user, database, auth_result + effective_user, database, auth_result ); } - stream.fatal(ErrorResponse::auth(user, database)).await?; + stream + .fatal(ErrorResponse::auth(effective_user, database)) + .await?; return Ok(None); } else { stream.send(&Authentication::Ok).await?; @@ -316,11 +384,13 @@ impl Client { return Ok(None); } - let mut conn = match Connection::new(user, database, admin) { + let mut conn = match Connection::new(effective_user, database, admin) { Ok(conn) => conn, Err(err) => { debug!("connection error: {}", err); - stream.fatal(ErrorResponse::auth(user, database)).await?; + stream + .fatal(ErrorResponse::auth(effective_user, database)) + .await?; return Ok(None); } }; @@ -336,7 +406,7 @@ impl Client { addr ); stream - .fatal(ErrorResponse::connection(user, database)) + .fatal(ErrorResponse::connection(effective_user, database)) .await?; return Ok(None); } else { diff --git a/pgdog/src/frontend/client/query_engine/mod.rs b/pgdog/src/frontend/client/query_engine/mod.rs index d90994c3a..f1e78b9a5 100644 --- a/pgdog/src/frontend/client/query_engine/mod.rs +++ b/pgdog/src/frontend/client/query_engine/mod.rs @@ -249,6 +249,12 @@ impl QueryEngine { Command::Copy(_) => self.execute(context).await?, Command::Deallocate => self.deallocate(context).await?, Command::Discard { extended } => self.discard(context, *extended).await?, + Command::RoleLocked { name } => { + // Client tried to escape a `server_role` impersonation. Reject + // the statement but keep the session alive. + self.error_response(context, ErrorResponse::role_locked(name)) + .await?; + } command => self.unknown_command(context, command.clone()).await?, } diff --git a/pgdog/src/frontend/router/parser/command.rs b/pgdog/src/frontend/router/parser/command.rs index 9a50cb9cc..760491790 100644 --- a/pgdog/src/frontend/router/parser/command.rs +++ b/pgdog/src/frontend/router/parser/command.rs @@ -54,6 +54,13 @@ pub enum Command { }, Unlisten(String), UniqueId, + /// A client tried to change the backend role (`SET ROLE`, `RESET ROLE`, + /// `SET SESSION AUTHORIZATION`, `set_config('role', ...)`, ...) on a pool + /// that impersonates a fixed `server_role`. Rejected with a 42501 error; + /// the session stays alive. `name` is the offending variable. + RoleLocked { + name: String, + }, } impl Command { diff --git a/pgdog/src/frontend/router/parser/query/mod.rs b/pgdog/src/frontend/router/parser/query/mod.rs index 587ec8d5c..8751892c0 100644 --- a/pgdog/src/frontend/router/parser/query/mod.rs +++ b/pgdog/src/frontend/router/parser/query/mod.rs @@ -290,6 +290,18 @@ impl QueryParser { .run()?; } + // Reject any client attempt to escape a `server_role` impersonation + // before routing. Single chokepoint over every statement form we + // forward (single SET, multi-statement batches, set_config with a + // non-constant value). Normal pools have no `server_role`, so this + // short-circuits. The engine turns `RoleLocked` into a 42501 error and + // keeps the session alive. + if context.router_context.cluster.server_role().is_some() + && let Some(name) = set_config::role_escape_target(stmts) + { + return Ok(Command::RoleLocked { name }); + } + // Handle multi-statement SET commands (e.g. "SET x TO 1; SET y TO 2"). if stmts.len() > 1 && let Some(command) = self.try_multi_set(&**stmts, context)? @@ -555,6 +567,14 @@ impl QueryParser { let stmts = &statement.parse_result().protobuf.stmts; + // Reject any client attempt to escape a `server_role` + // impersonation before routing (see the new_parser variant). + if context.router_context.cluster.server_role().is_some() + && let Some(name) = set_config::role_escape_target(stmts) + { + return Ok(Command::RoleLocked { name }); + } + // Handle multi-statement SET commands (e.g. "SET x TO 1; SET y TO 2"). if stmts.len() > 1 && let Some(command) = self.try_multi_set(stmts, context)? diff --git a/pgdog/src/frontend/router/parser/query/set_config.rs b/pgdog/src/frontend/router/parser/query/set_config.rs index 3c14a433a..955ff817b 100644 --- a/pgdog/src/frontend/router/parser/query/set_config.rs +++ b/pgdog/src/frontend/router/parser/query/set_config.rs @@ -1,6 +1,8 @@ #[cfg(not(feature = "new_parser"))] use super::String as PgString; use super::*; +#[cfg(feature = "new_parser")] +use pg_raw_parse::{Owned, StmtList}; #[cfg(not(feature = "new_parser"))] use std::string::String; @@ -48,6 +50,79 @@ impl QueryParser { } } +/// Session variables whose modification lets a client escape a `server_role` +/// impersonation. +const ROLE_ESCAPE_PARAMS: [&str; 2] = ["role", "session_authorization"]; + +fn is_role_escape(name: &str) -> bool { + ROLE_ESCAPE_PARAMS + .iter() + .any(|param| name.eq_ignore_ascii_case(param)) +} + +/// Find a client-issued attempt to change the backend role in any of the +/// parsed statements, so impersonation pools can reject every form uniformly: +/// `SET ROLE`, `SET SESSION ROLE`, `SET LOCAL ROLE`, `RESET ROLE`, +/// `SET SESSION AUTHORIZATION`, `RESET SESSION AUTHORIZATION`, and +/// `set_config('role'|'session_authorization', ...)` (including non-constant +/// values). Returns the offending variable name, matched case-insensitively. +/// +/// `RESET ALL` / `DISCARD ALL` are intentionally not matched: they restore +/// session defaults (the startup-parameter role) rather than clearing it. +#[cfg(feature = "new_parser")] +pub(super) fn role_escape_target(stmts: &Owned) -> Option { + for node in stmts.stmts() { + match node { + // SET ROLE / RESET ROLE / SET SESSION AUTHORIZATION, and their + // SESSION/LOCAL spellings, all land here with the same `name`. + Node::VariableSetStmt(stmt) => { + if let Some(name) = stmt.name() + && is_role_escape(name) + { + return Some(name.to_string()); + } + } + // SELECT set_config('role', , ...) — including a + // non-constant value that would otherwise pass through verbatim. + Node::SelectStmt(stmt) => { + if let Some(fcall) = extract_set_config(stmt) + && let Some(name) = fcall.args().first().and_then(Node::as_str) + && is_role_escape(name) + { + return Some(name.to_string()); + } + } + _ => (), + } + } + + None +} + +/// See the `new_parser` variant above for what this matches and why. +#[cfg(not(feature = "new_parser"))] +pub(super) fn role_escape_target(stmts: &[RawStmt]) -> Option { + for raw in stmts { + match raw.stmt.as_ref().and_then(|stmt| stmt.node.as_ref()) { + Some(NodeEnum::VariableSetStmt(stmt)) if is_role_escape(&stmt.name) => { + return Some(stmt.name.clone()); + } + Some(NodeEnum::SelectStmt(stmt)) => { + if let Some(fcall) = extract_set_config(stmt) + && let Some(name_arg) = fcall.args.first() + && let Some(name) = parse_config_name(name_arg) + && is_role_escape(&name) + { + return Some(name); + } + } + _ => (), + } + } + + None +} + /// Returns None if the arguments could not be parsed #[cfg(feature = "new_parser")] fn parse_args(fcall: &nodes::FuncCall) -> Option { diff --git a/pgdog/src/net/messages/error_response.rs b/pgdog/src/net/messages/error_response.rs index e03105cbb..5364b7680 100644 --- a/pgdog/src/net/messages/error_response.rs +++ b/pgdog/src/net/messages/error_response.rs @@ -100,6 +100,19 @@ impl ErrorResponse { } } + pub fn role_locked(name: &str) -> ErrorResponse { + ErrorResponse { + severity: "ERROR".into(), + code: "42501".into(), + message: format!( + "\"SET {}\" is not allowed: this connection impersonates a fixed role", + name + ), + routine: Some("client::QueryEngine::set".into()), + ..Default::default() + } + } + pub fn omni_in_direct_to_shard() -> ErrorResponse { ErrorResponse { severity: "ERROR".into(), From 477c40b1e605abc14aae37436dcc3433b7d2a954 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Mon, 20 Jul 2026 11:01:52 +0200 Subject: [PATCH 4/5] test: add authentication plugin integration suite Add test-plugin-auth (built on the Plugin trait) and an rspec suite exercising allow, deny, all-skip denial, a panicking plugin, derived-user provisioning, server_role impersonation, and the SET ROLE guard. Capture PgDog stderr into the integration log so the suite can assert on the plugin deny reason. --- integration/common.sh | 4 +- integration/plugins/auth/auth_spec.rb | 136 ++++++++++++++++++ integration/plugins/auth/pgdog.toml | 19 +++ integration/plugins/auth/setup.sql | 15 ++ integration/plugins/auth/users.toml | 25 ++++ integration/plugins/run.sh | 17 +++ .../test-plugins/test-plugin-auth/Cargo.toml | 16 +++ .../test-plugins/test-plugin-auth/src/lib.rs | 63 ++++++++ 8 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 integration/plugins/auth/auth_spec.rb create mode 100644 integration/plugins/auth/pgdog.toml create mode 100644 integration/plugins/auth/setup.sql create mode 100644 integration/plugins/auth/users.toml create mode 100644 integration/plugins/test-plugins/test-plugin-auth/Cargo.toml create mode 100644 integration/plugins/test-plugins/test-plugin-auth/src/lib.rs diff --git a/integration/common.sh b/integration/common.sh index 87f79c4e0..662c62fe4 100644 --- a/integration/common.sh +++ b/integration/common.sh @@ -63,10 +63,12 @@ function run_pgdog() { --users ${config_path}/users.toml \ > ${COMMON_DIR}/log.txt 2>&1 & else + # Capture stdout AND stderr: PgDog's tracing logs go to stderr, and the + # plugins auth suite asserts on log lines via integration/log.txt. "${binary}" \ --config ${config_path}/pgdog.toml \ --users ${config_path}/users.toml \ - > ${COMMON_DIR}/log.txt & + > ${COMMON_DIR}/log.txt 2>&1 & fi echo $! > "${pid_file}" printf '%s\n' "${config_path}" > "${config_file}" diff --git a/integration/plugins/auth/auth_spec.rb b/integration/plugins/auth/auth_spec.rb new file mode 100644 index 000000000..cb9b4d48d --- /dev/null +++ b/integration/plugins/auth/auth_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'pg' +require 'rspec' + +# PgDog's stdout/stderr is redirected here by integration/common.sh's run_pgdog. +# The authentication driver warn!-logs deny reasons, so the suite can assert +# they land in the log but never reach the client. +LOG_FILE = File.expand_path('../../../log.txt', __FILE__) + +GENERIC_AUTH_ERROR = /is wrong, or the database does not exist/ + +def connect(user, password, dbname: 'pgdog') + # Hash form avoids URL-encoding the ':' in credentials like + # "impersonate:reporting". + PG.connect(host: '127.0.0.1', port: 6432, user: user, password: password, dbname: dbname) +end + +def current_user(conn) + conn.exec('SELECT current_user AS u')[0]['u'] +end + +# Poll the PgDog log for a line matching `pattern`. Rust's stdout is +# line-buffered even when redirected, so a short poll is enough. +def wait_for_log(pattern, timeout: 5.0) + deadline = Time.now + timeout + loop do + return true if File.exist?(LOG_FILE) && File.read(LOG_FILE).match?(pattern) + return false if Time.now > deadline + + sleep 0.1 + end +end + +describe 'authentication plugin' do + it 'allows a client whose credential matches secret- and can query' do + conn = connect('alice', 'secret-alice') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'rejects a wrong credential with a generic auth error' do + expect { connect('alice', 'secret-bob') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'denies "deny" generically, logging the reason only in PgDog' do + error = nil + begin + connect('alice', 'deny') + raise 'expected the connection to be denied' + rescue PG::ConnectionBad => e + error = e + end + + # Client sees the generic error, never the plugin's reason. + expect(error.message).to match(GENERIC_AUTH_ERROR) + expect(error.message).not_to include('test deny') + + # PgDog logs the actual reason for the operator. + expect(wait_for_log(/denied by plugin .*test deny/)).to be(true) + end + + it 'survives a panicking plugin and keeps serving' do + expect { connect('alice', 'panic') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + + # PgDog is still alive: a subsequent good login works. + conn = connect('alice', 'secret-alice') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'denies an unknown credential when every plugin skips' do + expect { connect('alice', 'no-such-credential') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'provisions a pool and impersonates the derived role' do + conn = connect('reporting', 'impersonate:reporting') + expect(current_user(conn)).to eq('reporting') + conn.close + end + + it 'keeps the impersonated role across connection reuse and cleanup' do + conn = connect('reporting', 'impersonate:reporting') + + # Several transactions force the backend connection through the pool's + # cleanup path (RESET ALL / DISCARD ALL) between checkouts. Dirtying the + # session with a SET must not clear the role, which is a startup-parameter + # session default rather than a runtime SET. + 10.times do + conn.exec('BEGIN') + conn.exec("SET work_mem = '8MB'") + expect(current_user(conn)).to eq('reporting') + conn.exec('COMMIT') + end + + expect(current_user(conn)).to eq('reporting') + conn.close + end + + it 'rejects role escapes on the impersonation pool but keeps the session usable' do + conn = connect('reporting', 'impersonate:reporting') + + escapes = [ + 'SET ROLE someone_else', + 'RESET ROLE', + 'SET SESSION AUTHORIZATION someone_else', + # Multi-statement batch: the role change must be rejected even when it + # rides along with an innocuous statement. + 'SELECT 1; SET ROLE someone_else', + # set_config with a non-constant value would otherwise pass through + # verbatim, escaping the guard. + "SELECT set_config('role', 'some' || 'one_else', false)" + ] + escapes.each do |stmt| + expect { conn.exec(stmt) }.to raise_error(PG::Error, /impersonates a fixed role/) + # Session survives the rejection and the role is unchanged. + expect(current_user(conn)).to eq('reporting') + end + + # A multi-statement batch with no role change is still accepted. + conn.exec('SELECT 1; SELECT 2') + expect(current_user(conn)).to eq('reporting') + + conn.close + end + + it 'rejects INSERT on a read-only pool' do + conn = connect('readonly', 'secret-readonly') + expect { conn.exec('INSERT INTO auth_test (id) VALUES (1)') } + .to raise_error(PG::Error, /read-only transaction/) + conn.close + end +end diff --git a/integration/plugins/auth/pgdog.toml b/integration/plugins/auth/pgdog.toml new file mode 100644 index 000000000..6b24495c5 --- /dev/null +++ b/integration/plugins/auth/pgdog.toml @@ -0,0 +1,19 @@ +# +# Authentication-plugin integration suite. +# +# `auth_type = "plugin"` routes every non-admin login through the loaded +# authentication plugins. `test_plugin_auth` (see +# test-plugins/test-plugin-auth) makes the decisions the specs assert on. +# +[general] +auth_type = "plugin" +# Deny reasons are warn!-logged regardless, but log every connection attempt so +# the spec can also observe the auth-error line. +log_connections = true + +[[plugins]] +name = "test_plugin_auth" + +[[databases]] +name = "pgdog" +host = "127.0.0.1" diff --git a/integration/plugins/auth/setup.sql b/integration/plugins/auth/setup.sql new file mode 100644 index 000000000..70e8d5deb --- /dev/null +++ b/integration/plugins/auth/setup.sql @@ -0,0 +1,15 @@ +-- Postgres-side prerequisites for the authentication-plugin suite. +-- +-- Run directly against PostgreSQL (not through PgDog) as the `pgdog` service +-- account. `run.sh` applies this before starting PgDog. + +-- Role impersonated via `impersonate:reporting`. It needs no LOGIN: PgDog +-- connects as the `pgdog` service account and assumes the role through the +-- `role` startup parameter. Grant it to the service account so a non-superuser +-- deployment could assume it too. +DROP ROLE IF EXISTS reporting; +CREATE ROLE reporting NOLOGIN; +GRANT reporting TO pgdog; + +-- Target table for the read-only pool INSERT rejection test. +CREATE TABLE IF NOT EXISTS auth_test (id BIGINT); diff --git a/integration/plugins/auth/users.toml b/integration/plugins/auth/users.toml new file mode 100644 index 000000000..45a76ef42 --- /dev/null +++ b/integration/plugins/auth/users.toml @@ -0,0 +1,25 @@ +# +# Users for the authentication-plugin suite. +# +# With `auth_type = "plugin"` the client no longer supplies the Postgres +# password (the plugin authenticates the login), so these entries only define +# the backend pool: `server_user`/`server_password` are the credentials PgDog +# uses to connect to PostgreSQL. +# +# Impersonation users (e.g. `impersonate:reporting`) are not listed here: the +# plugin derives them and PgDog auto-provisions their pools. + +# Plain allow via `secret-alice`, no derivation, read-write. +[[users]] +name = "alice" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" + +# Read-only pool: `INSERT` must fail with a read-only error. +[[users]] +name = "readonly" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" +read_only = true diff --git a/integration/plugins/run.sh b/integration/plugins/run.sh index 5d60df708..3387f1a38 100644 --- a/integration/plugins/run.sh +++ b/integration/plugins/run.sh @@ -21,6 +21,10 @@ pushd ${SCRIPT_DIR}/test-plugins/test-plugin-compatible build_plugin popd +pushd ${SCRIPT_DIR}/test-plugins/test-plugin-auth +build_plugin +popd + pushd ${SCRIPT_DIR}/test-plugins/test-plugin-outdated cargo build --release popd @@ -40,3 +44,16 @@ wait_for_pgdog bash ${SCRIPT_DIR}/dev.sh stop_pgdog + +# Phase 2: authentication plugin (auth_type = "plugin"). +# Postgres-side prerequisites (impersonated role, target table) go in first, +# applied directly against PostgreSQL rather than through PgDog. +PGPASSWORD=pgdog psql -h 127.0.0.1 -p 5432 -U pgdog -d pgdog -v ON_ERROR_STOP=1 \ + -f ${SCRIPT_DIR}/auth/setup.sql + +run_pgdog ${SCRIPT_DIR}/auth +wait_for_pgdog +pushd ${SCRIPT_DIR} +bundle exec rspec auth/auth_spec.rb +popd +stop_pgdog diff --git a/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml b/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml new file mode 100644 index 000000000..2efcd6c16 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "test-plugin-auth" +version = "0.1.0" +edition = "2024" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pgdog-plugin = { path = "../../../../pgdog-plugin", default-features = false } + +[features] +default = ["pgdog-plugin/pg_query"] +new_parser = ["pgdog-plugin/new_parser"] diff --git a/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs new file mode 100644 index 000000000..4011e63f1 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs @@ -0,0 +1,63 @@ +//! Authentication plugin used by the integration suite. +//! +//! It exercises the `authenticate` extension point added in this PR. The +//! credential the client sends (as a cleartext password) drives the decision: +//! +//! - `"deny"` => [`AuthDecision::Deny`] with a reason PgDog logs +//! but never sends to the client. +//! - `"panic"` => `panic!()`. The plugin runs on PgDog's blocking +//! pool, which catches the unwind, so PgDog denies the client and stays up. +//! - `"secret-"` => [`AuthDecision::Allow`] with no derivation; the +//! client connects to its pre-configured pool. +//! - `"impersonate:"` => [`AuthDecision::Allow`] deriving ``, +//! impersonating it via `server_role`, and asking PgDog to provision a pool. +//! - anything else => [`AuthDecision::Skip`] (all-skip => PgDog denies). + +use pgdog_plugin::{AuthContext, AuthDecision, AuthGrant, PdStr, Plugin, plugin}; + +plugin!(TestAuthPlugin); + +struct TestAuthPlugin; + +/// Backend credentials PgDog uses when a grant provisions a pool. The +/// integration `setup.sh` creates the `pgdog` superuser with this password, and +/// the suite's `setup.sql` grants the impersonated roles to it. A real plugin +/// would read these from its own config; hardcoding keeps the test hermetic. +const SERVER_USER: &str = "pgdog"; +const SERVER_PASSWORD: &str = "pgdog"; + +impl Plugin for TestAuthPlugin { + extern "C-unwind" fn version() -> PdStr<'static> { + env!("CARGO_PKG_VERSION").into() + } + + fn authenticate(context: AuthContext<'_>) -> AuthDecision { + let credential = &*context.credential; + + if credential == "deny" { + return AuthDecision::Deny("test deny".into()); + } + + if credential == "panic" { + panic!("test plugin panic"); + } + + if let Some(role) = credential.strip_prefix("impersonate:") { + return AuthDecision::Allow(AuthGrant { + derived_user: Some(role.to_string()), + server_role: Some(role.to_string()), + server_user: Some(SERVER_USER.to_string()), + server_password: Some(SERVER_PASSWORD.to_string()), + read_only: None, + provision: true, + }); + } + + // `secret-` allows the matching client through to its configured pool. + if credential == format!("secret-{}", &*context.user) { + return AuthDecision::Allow(AuthGrant::default()); + } + + AuthDecision::Skip + } +} From 44c7358077f6fcded684fa4b76e5fb766b985c93 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Mon, 20 Jul 2026 11:01:53 +0200 Subject: [PATCH 5/5] chore: regenerate JSON schema for auth-plugin config fields --- .schema/pgdog.schema.json | 13 +++++++++++++ .schema/users.schema.json | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index e5d0a4ff9..4d800f328 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -26,6 +26,7 @@ "$ref": "#/$defs/General", "default": { "auth_type": "scram", + "background_workers": 4, "ban_replica_lag": 9223372036854775807, "ban_replica_lag_bytes": 9223372036854775807, "ban_timeout": 300000, @@ -312,6 +313,11 @@ "description": "Plaintext password.", "type": "string", "const": "plain" + }, + { + "description": "Delegate client authentication to a loaded plugin exposing the `pgdog_authenticate` hook.", + "type": "string", + "const": "plugin" } ] }, @@ -595,6 +601,13 @@ "$ref": "#/$defs/AuthType", "default": "scram" }, + "background_workers": { + "description": "Maximum number of authentication-plugin calls allowed to run concurrently.\nAuthentication plugins run on a blocking thread pool (auth happens once per\nconnection and may block on I/O); this bounds how many run at the same time.\n\n**Note:** Only relevant when `auth_type = \"plugin\"`.\n\n_Default:_ `4`\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#background_workers", + "type": "integer", + "format": "uint", + "default": 4, + "minimum": 0 + }, "ban_replica_lag": { "description": "Ban a replica from serving read queries if its replication lag (in milliseconds) exceeds this threshold.\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#ban_replica_lag", "type": "integer", diff --git a/.schema/users.schema.json b/.schema/users.schema.json index f0c54fad1..831b51763 100644 --- a/.schema/users.schema.json +++ b/.schema/users.schema.json @@ -268,6 +268,13 @@ "null" ] }, + "server_role": { + "description": "PostgreSQL role this user's backend connections assume via the `role` startup\nparameter, used for impersonation with `auth_type = \"plugin\"`.\n\n**Note:** The user config no longer supplies the Postgres password when a plugin\nderives the login, so a working backend credential must be provided via\n`server_password` or a non-default `server_auth`. `SET ROLE`, `RESET ROLE`, and\n`SET SESSION AUTHORIZATION` are rejected on pools that set this.", + "type": [ + "string", + "null" + ] + }, "server_user": { "description": "Which user to connect with when creating backend connections from PgDog to PostgreSQL. By default, the user configured in `name` is used. This setting allows you to override this configuration and use a different user.\n\n**Note:** Values specified in `pgdog.toml` take priority over this configuration.\n\nhttps://docs.pgdog.dev/configuration/users.toml/users/#server_user", "type": [