From 9bd5fccfc0faee6a87cb439ca46335b4b85285a0 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:09:17 +0200 Subject: [PATCH 01/13] feat: add authenticate hook to the pgdog-plugin ABI Plugins can now take part in client authentication. A plugin implements Plugin::authenticate, receives the user, database, credential, client address and TLS details as borrowed strings, and returns Skip, Allow or Deny. Allow can carry a derived user, a server_role to assume on the backend, backend credentials, a read-only flag and a provision flag. No ownership crosses the FFI boundary: the owned decision stays on the plugin's stack and each string field is streamed to the host through a sink callback as a borrowed PdStr while the call is in progress, so there is nothing to free on either side. A panic inside the plugin's authenticate is caught on the plugin side and turned into a Deny, so it never unwinds into the host as a foreign exception. The hook is a new slot appended to PluginVtable, which is an ABI change, so pgdog-plugin moves from 0.4.0 to 0.5.0. The loader compares major.minor, so plugins built against 0.4 are skipped with a warning rather than loaded against a vtable they do not match. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- pgdog-plugin/Cargo.toml | 2 +- pgdog-plugin/src/auth.rs | 195 ++++++++++++++++++++++++++++++++++++ pgdog-plugin/src/lib.rs | 56 +++++++++++ pgdog-plugin/src/plugin.rs | 117 ++++++++++++++++++++++ pgdog-plugin/src/prelude.rs | 1 + 7 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 pgdog-plugin/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index ed2671ce0..59aea83b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3237,7 +3237,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 41fff8e2a..af3efd1ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,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 = "6a6e16719b48eae2c5897e2e28de8d2bd65184f9" } diff --git a/pgdog-plugin/Cargo.toml b/pgdog-plugin/Cargo.toml index d8975e68d..f8f0faeae 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..3698faed8 --- /dev/null +++ b/pgdog-plugin/src/auth.rs @@ -0,0 +1,195 @@ +//! 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, + /// Read-only flag, encoded as [`AuthOutcome::READ_ONLY_FALSE`], + /// [`AuthOutcome::READ_ONLY_TRUE`] or [`AuthOutcome::READ_ONLY_UNSET`]. + /// Decode it with [`AuthOutcome::read_only_flag`]. + pub read_only: u8, + /// Auto-provision the derived user's pool. + pub provision: bool, +} + +impl AuthOutcome { + /// [`AuthOutcome::read_only`] code for a read-write pool. + pub const READ_ONLY_FALSE: u8 = 0; + /// [`AuthOutcome::read_only`] code for a read-only pool. + pub const READ_ONLY_TRUE: u8 = 1; + /// [`AuthOutcome::read_only`] code for a flag the plugin left unset. + pub const READ_ONLY_UNSET: u8 = 2; + + /// The neutral "defer to the next plugin" outcome. + pub(crate) const fn skip() -> Self { + Self { + tag: AuthDecisionTag::Skip, + read_only: Self::READ_ONLY_UNSET, + provision: false, + } + } + + /// Decode [`AuthOutcome::read_only`] into the flag the plugin returned in + /// its [`AuthGrant`]. + /// + /// Codes this crate does not define decode to `None`, so a plugin built + /// against a newer version cannot make the host act on a value it does not + /// understand. + pub const fn read_only_flag(&self) -> Option { + match self.read_only { + Self::READ_ONLY_FALSE => Some(false), + Self::READ_ONLY_TRUE => Some(true), + _ => None, + } + } +} + +/// Encode an `Option` read-only flag as its [`AuthOutcome::read_only`] +/// code. The inverse of [`AuthOutcome::read_only_flag`]. +pub(crate) const fn read_only_code(value: Option) -> u8 { + match value { + None => AuthOutcome::READ_ONLY_UNSET, + Some(false) => AuthOutcome::READ_ONLY_FALSE, + Some(true) => AuthOutcome::READ_ONLY_TRUE, + } +} + +/// 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. +/// +/// The host implementation must not panic: it is reached from the plugin +/// through an `extern "C-unwind"` call, and an unwind back out of the shared +/// library is a foreign exception the host cannot catch. See +/// [`PluginVtable::authenticate`](crate::PluginVtable::authenticate). +pub type AuthSink = extern "C-unwind" fn(*mut c_void, AuthField, PdStr<'_>); + +#[cfg(test)] +mod test { + use super::*; + + fn outcome(read_only: u8) -> AuthOutcome { + AuthOutcome { + tag: AuthDecisionTag::Allow, + read_only, + provision: false, + } + } + + #[test] + fn test_read_only_round_trip() { + for value in [None, Some(true), Some(false)] { + assert_eq!(outcome(read_only_code(value)).read_only_flag(), value); + } + } + + #[test] + fn test_unknown_read_only_code_is_unset() { + assert_eq!(outcome(7).read_only_flag(), None); + assert_eq!(AuthOutcome::skip().read_only_flag(), None); + } +} diff --git a/pgdog-plugin/src/lib.rs b/pgdog-plugin/src/lib.rs index 9bbd6ffd2..ecf4b19a5 100644 --- a/pgdog-plugin/src/lib.rs +++ b/pgdog-plugin/src/lib.rs @@ -150,6 +150,60 @@ //! } //! ``` //! +//! # Authenticating clients +//! +//! Plugins can also take part in client login. With `auth_type = "plugin"` in +//! `pgdog.toml`, PgDog asks the client for a cleartext credential and offers it +//! to each plugin in configuration order; the first one that does not return +//! [`AuthDecision::Skip`] decides the login. [`AuthDecision::Allow`] carries an +//! [`AuthGrant`], which can derive the PostgreSQL user the session runs as, +//! assume a `server_role` on the backend, supply backend credentials and +//! provision the pool. +//! +//! [`Plugin::authenticate`] differs from [`Plugin::route`] in two ways: PgDog +//! calls it from its blocking thread pool, so blocking I/O such as a request to +//! an identity provider is expected (apply your own timeout), and a panic is +//! caught and turned into a denial rather than taking the process down. +//! +//! #### Example +//! +//! ``` +//! use pgdog_plugin::prelude::*; +//! +//! pgdog_plugin::plugin!(MyPlugin); +//! +//! struct MyPlugin; +//! +//! impl Plugin for MyPlugin { +//! # extern "C-unwind" fn version() -> PdStr<'static> { +//! # env!("CARGO_PKG_VERSION").into() +//! # } +//! +//! fn authenticate(context: AuthContext<'_>) -> AuthDecision { +//! // Credentials this plugin doesn't recognize are left to the next +//! // plugin, so one deployment can mix token and password logins. +//! let Some(token) = context.credential.strip_prefix("tok_") else { +//! return AuthDecision::Skip; +//! }; +//! +//! match verify(token) { +//! Some(email) => AuthDecision::Allow(AuthGrant { +//! // Run the session as the authenticated identity. +//! derived_user: Some(email.clone()), +//! server_role: Some(email), +//! provision: true, +//! ..Default::default() +//! }), +//! None => AuthDecision::Deny("token not recognized".into()), +//! } +//! } +//! } +//! +//! # fn verify(_token: &str) -> Option { +//! # Some("alice@example.com".into()) +//! # } +//! ``` +//! //! # Enabling plugins //! //! Plugins are shared libraries, loaded by PgDog at runtime using `dlopen(3)`. If specifying only its name, make sure to place the plugin's shared library @@ -175,6 +229,7 @@ //! ``` //! +pub mod auth; mod config; pub mod context; pub mod logging; @@ -184,6 +239,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 571b17788..4c2ad2a8a 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}; @@ -48,6 +50,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 { @@ -115,6 +120,82 @@ 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). + /// + /// # Concurrency + /// + /// PgDog calls this from its blocking thread pool, so clients can be + /// authenticated concurrently and on different threads; implementations + /// must be thread-safe. Unlike [`Plugin::route`], blocking I/O is expected + /// here (an HTTP round trip to an identity provider, for example) and does + /// not stall PgDog's runtime. Apply your own timeout: a call that never + /// returns holds on to a thread in that pool. + /// + /// # Panics + /// + /// A panic is caught before it can unwind across the FFI boundary and is + /// turned into [`AuthDecision::Deny`], so a bug here rejects one client + /// instead of aborting PgDog. + 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: AuthOutcome::READ_ONLY_UNSET, + 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 { @@ -129,6 +210,7 @@ impl PluginVtable { plugin_version: T::version, pgdog_plugin_api_version: T::plugin_api_version, logging_init: T::logging_init, + authenticate: T::authenticate_raw, } } @@ -208,4 +290,39 @@ 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. + /// + /// # Panics + /// + /// `on_field` must not panic. It is reached from the plugin through an + /// `extern "C-unwind"` trampoline, and an unwind back out of the shared + /// library is a foreign exception that aborts the process instead of + /// being caught. Copy the value here and do fallible work with it after + /// this call returns. + 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 a74c21cd5..5c53e099f 100644 --- a/pgdog-plugin/src/prelude.rs +++ b/pgdog-plugin/src/prelude.rs @@ -2,5 +2,6 @@ pub use crate::{ Context, ParameterFormat, PdStr, Plugin, ReadWrite, Route, Shard, + auth::{AuthContext, AuthDecision, AuthGrant}, parameters::{Parameter, ParameterValue, Parameters}, }; From 5c461020aaedf238c8d51a26ae3afea934da15cc Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:09:18 +0200 Subject: [PATCH 02/13] feat: keep plugin load order and per-plugin library slots Store loaded plugins in an IndexMap so they are consulted in the order they appear in pgdog.toml. HashMap iteration order was arbitrary, which made "first plugin to answer wins" nondeterministic for router plugins and would make ordering authentication plugins impossible. Keep one Option slot per configured plugin instead of dropping failed dlopen results from the vector. Previously a plugin that failed to load shifted every later library down one index, so the next plugin name was paired with the wrong library. Also drop the unwrap on LIBS right after setting it. Co-Authored-By: Claude Fable 5.1 --- pgdog/src/plugin/mod.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pgdog/src/plugin/mod.rs b/pgdog/src/plugin/mod.rs index e60c943a3..8ca0aa769 100644 --- a/pgdog/src/plugin/mod.rs +++ b/pgdog/src/plugin/mod.rs @@ -1,17 +1,17 @@ //! pgDog plugins. +use indexmap::IndexMap; use once_cell::sync::OnceCell; use pgdog_config::{Config, LogFormat}; use pgdog_plugin::libloading; use pgdog_plugin::libloading::Library; use pgdog_plugin::{Config as PdConfig, PdStr, PluginVtable}; use semver::Version; -use std::collections::HashMap; use tokio::time::Instant; use tracing::{debug, error, info, warn}; -static LIBS: OnceCell> = OnceCell::new(); -pub(crate) static PLUGINS: OnceCell> = OnceCell::new(); +static LIBS: OnceCell>> = OnceCell::new(); +pub(crate) static PLUGINS: OnceCell> = OnceCell::new(); // Compare semantic versions by major and minor only (ignore patch/bugfix). fn same_major_minor(a: &str, b: &str) -> bool { @@ -39,7 +39,7 @@ pub(crate) fn load(config: &Config) -> Result<(), libloading::Error> { let libs = plugins .iter() - .filter_map(|plugin| { + .map(|plugin| { PluginVtable::library(&plugin.name) .map_err(|err| error!("plugin \"{}\" failed to load: {:#?}", plugin.name, err)) .ok() @@ -51,8 +51,12 @@ pub(crate) fn load(config: &Config) -> Result<(), libloading::Error> { let rustc_version = pgdog_plugin::RUSTC_VERSION; let pgdog_plugin_api_version = pgdog_plugin::VERSION; - let plugin_libs = plugins.iter().enumerate().filter_map(|(i, plugin)| { - if let Some(lib) = LIBS.get().unwrap().get(i) { + let Some(libs) = LIBS.get() else { + return Ok(()); + }; + + let plugin_libs = plugins.iter().zip(libs).filter_map(|(plugin, lib)| { + if let Some(lib) = lib { let now = Instant::now(); let Some(plugin_lib) = PluginVtable::load(lib) else { warn!( @@ -139,7 +143,7 @@ pub(crate) fn shutdown() { } /// Get all loaded plugins. -pub(crate) fn plugins() -> Option<&'static HashMap> { +pub(crate) fn plugins() -> Option<&'static IndexMap> { PLUGINS.get() } From 5f0e61ec378f492b5d852529778f418c13d35466 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:38:18 +0200 Subject: [PATCH 03/13] feat: per-user server_role to impersonate a Postgres role on the backend A [[users]] entry can set server_role. The pool keeps connecting as server_user and passes role= in the startup packet, so the role is the session's reset value: RESET ROLE and DISCARD ALL fall back to it, RESET ALL leaves it untouched, and connection cleanup never clears it. The setting is plumbed from the user config through Address into the pool's startup parameters, next to default_transaction_read_only. Config load warns when a server_role user has no backend credential of its own (no server_password, no plain password, password server_auth), since PgDog could not open server connections for it. The users.toml JSON schema is regenerated and example.users.toml documents the setting. Co-Authored-By: Claude Fable 5.1 --- .schema/users.schema.json | 7 +++ example.users.toml | 18 ++++++ pgdog-config/src/users.rs | 61 +++++++++++++++++++ .../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/pool/address.rs | 6 ++ pgdog/src/backend/pool/pool_impl.rs | 12 ++++ pgdog/src/backend/pool/test/mod.rs | 29 +++++++++ 9 files changed, 136 insertions(+) diff --git a/.schema/users.schema.json b/.schema/users.schema.json index 2333addd4..3a78933bf 100644 --- a/.schema/users.schema.json +++ b/.schema/users.schema.json @@ -329,6 +329,13 @@ "null" ] }, + "server_role": { + "description": "PostgreSQL role this user's backend connections assume. PgDog connects\nas `server_user` and passes `role` in the startup packet, which makes\nit the session's reset value: `RESET ROLE` and `DISCARD ALL` fall back\nto it and `RESET ALL` leaves it untouched, so connection cleanup never\nclears it. Clients on this pool cannot change it: `SET ROLE`,\n`RESET ROLE`, `SET SESSION AUTHORIZATION` and the `set_config(...)`\nspellings are rejected with a permission error.\n\n**Note:** `server_user` needs a working backend credential of its own\n(`server_password` or a non-password `server_auth`) and must be a\nmember of `server_role`.", + "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\n", "type": [ diff --git a/example.users.toml b/example.users.toml index 564711555..b732850f2 100644 --- a/example.users.toml +++ b/example.users.toml @@ -48,3 +48,21 @@ password = "pgdog" # a client certificate, while mTLS users share the same listener. Defaults to true. # Only applies when `tls_client_ca_certificate` is set in pgdog.toml. # tls_client_certificate_required = false + +# Example: role impersonation. Backend connections for this user log in as +# `server_user` and assume `server_role` through the `role` startup parameter, +# which makes it the session's reset value: `RESET ROLE` / `DISCARD ALL` fall +# back to it and `RESET ALL` leaves it untouched. +# `SET ROLE`, `RESET ROLE` and `SET SESSION AUTHORIZATION` (including the +# `set_config(...)` spellings) are rejected on this pool with a permission error. +# +# `server_user` must be a member of `server_role` and needs its own backend +# credential (`server_password` or a non-password `server_auth`); PgDog warns +# at config load if none is configured. +# [[users]] +# name = "analytics" +# database = "pgdog" +# password = "analytics" +# server_role = "analytics_ro" +# server_user = "pgdog" +# server_password = "pgdog" diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 86210e32f..4df58c6ee 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -90,6 +90,20 @@ impl Users { ); } + if user.server_role.is_some() + && user.server_auth == ServerAuth::Password + && user.server_password.is_none() + && !user + .passwords() + .iter() + .any(|p| matches!(p, PasswordKind::Plain(_))) + { + warn!( + r#"user "{}" (database "{}") sets "server_role" but has no backend credential ("server_password" or a non-password "server_auth"), PgDog cannot connect to the server to impersonate it"#, + user.name, user.database + ); + } + 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"#, @@ -326,6 +340,19 @@ pub struct User { /// /// pub server_password: Option, + /// PostgreSQL role this user's backend connections assume. PgDog connects + /// as `server_user` and passes `role` in the startup packet, which makes + /// it the session's reset value: `RESET ROLE` and `DISCARD ALL` fall back + /// to it and `RESET ALL` leaves it untouched, so connection cleanup never + /// clears it. + /// + /// This is impersonation, not an authorization boundary: what the client + /// can reach is limited by which roles `server_user` is a member of. + /// + /// **Note:** `server_user` needs a working backend credential of its own + /// (`server_password` or a non-password `server_auth`) and must be a + /// member of `server_role`. + pub server_role: Option, /// Backend auth mode for server connections. #[serde(default)] pub server_auth: ServerAuth, @@ -870,6 +897,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()); diff --git a/pgdog/src/backend/auth/azure_workload_identity.rs b/pgdog/src/backend/auth/azure_workload_identity.rs index 6ed677b4d..d63c4f4ad 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, server_iam_assume_role: None, vault_path: Default::default(), diff --git a/pgdog/src/backend/auth/rds_iam.rs b/pgdog/src/backend/auth/rds_iam.rs index 0eca0e494..53c838778 100644 --- a/pgdog/src/backend/auth/rds_iam.rs +++ b/pgdog/src/backend/auth/rds_iam.rs @@ -157,6 +157,7 @@ mod tests { passwords: vec![], database_number: 0, server_auth: ServerAuth::RdsIam, + server_role: None, server_iam_region: Some("us-east-1".into()), server_iam_assume_role: None, vault_path: Default::default(), diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 18e72912b..69da39ec1 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, server_iam_assume_role: None, vault_path: vault_path.map(Into::into), diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 906c41d18..286c0fe07 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -30,6 +30,10 @@ pub(crate) struct Address { /// Server auth mode for backend connections. #[serde(default)] pub(crate) server_auth: ServerAuth, + /// PostgreSQL role backend connections assume via the `role` startup + /// parameter, from `User.server_role`. + #[serde(default)] + pub(crate) server_role: Option, /// Optional IAM region override. pub(crate) server_iam_region: Option, /// Optional IAM role ARN to assume before minting the RDS IAM token, for @@ -101,6 +105,7 @@ impl Address { .collect() }, server_auth, + server_role: user.server_role.clone(), server_iam_region: user.server_iam_region.clone(), server_iam_assume_role: user.server_iam_assume_role.clone(), vault_path: user.server_vault_path.clone(), @@ -220,6 +225,7 @@ impl Address { passwords: vec!["pgdog".into()], database_name: "pgdog".into(), server_auth: ServerAuth::Password, + server_role: None, server_iam_region: None, server_iam_assume_role: None, vault_path: None, diff --git a/pgdog/src/backend/pool/pool_impl.rs b/pgdog/src/backend/pool/pool_impl.rs index a3a800f4c..d566fbaf0 100644 --- a/pgdog/src/backend/pool/pool_impl.rs +++ b/pgdog/src/backend/pool/pool_impl.rs @@ -486,6 +486,18 @@ impl Pool { }); } + // Impersonation role from `User.server_role`. Sent in the startup + // packet so it becomes the session's reset value: `RESET ROLE` and + // `DISCARD ALL` fall back to it and `RESET ALL` leaves it alone + // (`role` is GUC_NO_RESET_ALL), so cleanup between checkouts never + // clears it. + 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/backend/pool/test/mod.rs b/pgdog/src/backend/pool/test/mod.rs index 4201a3c71..070294977 100644 --- a/pgdog/src/backend/pool/test/mod.rs +++ b/pgdog/src/backend/pool/test/mod.rs @@ -1260,3 +1260,32 @@ async fn test_move_conns_to_does_not_pause_destination_when_source_is_not_paused destination.shutdown(); } + +#[test] +fn test_server_options_role() { + let pool = Pool::new(&PoolConfig { + address: Address { + server_role: Some("analytics".into()), + ..Address::new_test() + }, + config: Config::default(), + }); + let options = pool.server_options(ConnectReason::default()); + let role = options + .params + .iter() + .find(|p| p.name == "role") + .expect("role startup parameter"); + assert_eq!(role.value.as_str(), Some("analytics")); + + let pool = Pool::new(&PoolConfig { + address: Address::new_test(), + config: Config::default(), + }); + assert!( + pool.server_options(ConnectReason::default()) + .params + .iter() + .all(|p| p.name != "role") + ); +} From 9da894a44b94b3244943c0f0a6e0151c5a531777 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:38:18 +0200 Subject: [PATCH 04/13] feat: reject role changes on pools that impersonate a server_role Clients on a pool with server_role must not be able to leave the role. The query parser flags SET ROLE, RESET ROLE, SET SESSION AUTHORIZATION (and its RESET), the set_config('role', ...) and set_config('session_authorization', ...) spellings, and any of those inside a multi-statement query, as Command::RoleLocked. The query engine answers with a 42501 error, marks an open transaction as aborted, and keeps the session alive. Cluster::use_query_parser forces full parsing whenever server_role is set, because the regex fast path would let SELECT set_config('role', ...) through. Since that silently overrides query_parser = "off", the config check now warns about it at load. The parser cannot see every way to reach SET ROLE: a DO block, a function body, or a computed set_config() name all bypass it. Those escapes stay possible inside the client's own session, where the real boundary is which roles server_user is a member of, but they must not outlive it: role is GUC_NO_RESET_ALL, so RESET ALL leaves an escaped role in place and the next client to check that connection out would inherit it. Pool cleanup now resets the role on check-in for these pools, which restores the startup-packet value, i.e. the impersonated role. A pool test escapes the role behind the parser's back and asserts the next checkout of the same connection is back to the impersonated one. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) --- pgdog-config/src/users.rs | 22 ++++- pgdog/src/backend/pool/cleanup.rs | 69 ++++++++++++-- pgdog/src/backend/pool/cluster.rs | 45 ++++++++++ pgdog/src/backend/pool/test/mod.rs | 58 ++++++++++++ .../frontend/client/query_engine/context.rs | 11 +++ 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 | 9 ++ .../router/parser/query/set_config.rs | 82 +++++++++++++++++ .../frontend/router/parser/query/test/mod.rs | 1 + .../router/parser/query/test/setup.rs | 6 ++ .../parser/query/test/test_server_role.rs | 90 +++++++++++++++++++ pgdog/src/net/messages/error_response.rs | 13 +++ 13 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 pgdog/src/frontend/router/parser/query/test/test_server_role.rs diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 4df58c6ee..1d18fb7c9 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -8,6 +8,7 @@ use tracing::warn; use super::core::Config; use super::pooling::PoolerMode; use crate::RoleConfig; +use crate::sharding::QueryParserLevel; use crate::util::random_string; use schemars::JsonSchema; @@ -104,6 +105,16 @@ impl Users { ); } + // The role guard needs the AST of every statement, so these pools + // parse queries whatever `query_parser` says. Say so instead of + // silently ignoring the setting. + if user.server_role.is_some() && config.general.query_parser == QueryParserLevel::Off { + warn!( + r#"user "{}" (database "{}") sets "server_role", so its queries are parsed even though "query_parser" is off"#, + user.name, user.database + ); + } + 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"#, @@ -346,8 +357,15 @@ pub struct User { /// to it and `RESET ALL` leaves it untouched, so connection cleanup never /// clears it. /// - /// This is impersonation, not an authorization boundary: what the client - /// can reach is limited by which roles `server_user` is a member of. + /// Clients on this pool are stopped from changing it: `SET ROLE`, + /// `RESET ROLE`, `SET SESSION AUTHORIZATION` and the `set_config(...)` + /// spellings are rejected with a permission error, and the role is + /// restored when the connection is checked back in, so a statement the + /// parser does not recognize (a `DO` block, a function body) cannot leak a + /// role to the next session. Within a session this is not an authorization + /// boundary: the real limit on what the client can reach is which roles + /// `server_user` is a member of. Queries on these pools are always parsed, + /// whatever `query_parser` is set to. /// /// **Note:** `server_user` needs a working backend credential of its own /// (`server_password` or a non-password `server_auth`) and must be a diff --git a/pgdog/src/backend/pool/cleanup.rs b/pgdog/src/backend/pool/cleanup.rs index df077a4ca..024f672c5 100644 --- a/pgdog/src/backend/pool/cleanup.rs +++ b/pgdog/src/backend/pool/cleanup.rs @@ -23,6 +23,34 @@ static ALL: Lazy> = Lazy::new(|| vec!["DISCARD ALL"].into_iter().map(Query::new).collect()); static NONE: Lazy> = Lazy::new(Vec::new); +/// `RESET ROLE` restores the role from the startup packet, which on a pool +/// with `server_role` is the impersonated role, not "no role". +/// +/// `RESET ALL` does not do this: `role` is `GUC_NO_RESET_ALL` in PostgreSQL +/// and is skipped. Only `DISCARD ALL` covers it, through its implicit +/// `SET SESSION AUTHORIZATION DEFAULT`. +static ROLE: Lazy> = Lazy::new(|| vec![Query::new("RESET ROLE")]); +static DIRTY_ROLE: Lazy> = Lazy::new(|| { + let mut queries = DIRTY.clone(); + queries.push(Query::new("RESET ROLE")); + queries +}); +static PREPARED_ROLE: Lazy> = Lazy::new(|| { + let mut queries = PREPARED.clone(); + queries.push(Query::new("RESET ROLE")); + queries +}); + +/// Whether the connection belongs to a pool that impersonates a `server_role` +/// and therefore has to have that role restored before it is reused. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum RoleReset { + /// The pool does not impersonate a role. + NotNeeded, + /// Restore the startup-packet role on check-in. + Needed, +} + /// Queries used to clean up server connections after /// client modifications. pub(crate) struct Cleanup { @@ -60,12 +88,27 @@ impl std::fmt::Display for Cleanup { impl Cleanup { /// New cleanup operation. pub(crate) fn new(guard: &Guard, server: &mut Server) -> Self { + // A pool that impersonates a role has to put that role back before the + // connection serves another session. The query parser rejects the + // statements that change `role`, but it cannot see every spelling (a + // `DO` block, a function body, a computed `set_config` name), and + // `RESET ALL` skips `role`, so without this an escaped role would + // outlive the session that set it. + let role = if server.addr().server_role.is_some() { + RoleReset::Needed + } else { + RoleReset::NotNeeded + }; + let mut clean = if guard.reset { + // `DISCARD ALL` already restores the startup-packet role. Self::all() } else if server.dirty() { - Self::parameters() + Self::parameters(role) } else if server.schema_changed() { - Self::prepared_statements() + Self::prepared_statements(role) + } else if role == RoleReset::Needed { + Self::role() } else { Self::none() }; @@ -81,23 +124,37 @@ impl Cleanup { } /// Cleanup prepared statements. - pub(crate) fn prepared_statements() -> Self { + pub(super) fn prepared_statements(role: RoleReset) -> Self { Self { - queries: &*PREPARED, + queries: match role { + RoleReset::NotNeeded => &*PREPARED, + RoleReset::Needed => &*PREPARED_ROLE, + }, deallocate: true, ..Default::default() } } /// Cleanup parameters. - pub(crate) fn parameters() -> Self { + pub(super) fn parameters(role: RoleReset) -> Self { Self { - queries: &*DIRTY, + queries: match role { + RoleReset::NotNeeded => &*DIRTY, + RoleReset::Needed => &*DIRTY_ROLE, + }, dirty: true, ..Default::default() } } + /// Restore the impersonated role and nothing else. + pub(super) fn role() -> Self { + Self { + queries: &*ROLE, + ..Default::default() + } + } + /// Cleanup everything. pub(crate) fn all() -> Self { Self { diff --git a/pgdog/src/backend/pool/cluster.rs b/pgdog/src/backend/pool/cluster.rs index d7fcc9b8d..c68024356 100644 --- a/pgdog/src/backend/pool/cluster.rs +++ b/pgdog/src/backend/pool/cluster.rs @@ -81,6 +81,7 @@ pub(crate) struct Cluster { sharding_lookup_timeout: Duration, regex_parser: RegexParser, identity: Option, + server_role: Option, tls_client_certificate_required: bool, #[debug(skip)] schema_loader: Box, @@ -132,6 +133,7 @@ impl Default for Cluster { sharding_lookup_timeout: Duration::from_millis(General::sharding_lookup_timeout()), regex_parser: Default::default(), identity: Default::default(), + server_role: Default::default(), tls_client_certificate_required: Default::default(), schema_loader: Default::default(), canonical_oids: Default::default(), @@ -222,6 +224,7 @@ pub(crate) struct ClusterConfig<'a> { regex_parser_limit: usize, pub_sub_enabled: bool, identity: &'a Option, + server_role: Option, tls_client_certificate_required: bool, schema_cache: SchemaCache, canonicalize_oids: bool, @@ -292,6 +295,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(), tls_client_certificate_required: user.tls_client_certificate_required.unwrap_or(true), schema_cache, canonicalize_oids: general.canonicalize_type_information, @@ -339,6 +343,7 @@ impl Cluster { regex_parser_limit, pub_sub_enabled, identity, + server_role, tls_client_certificate_required, schema_cache, canonicalize_oids, @@ -412,6 +417,7 @@ impl Cluster { sharding_lookup_timeout: Duration::from_millis(sharding_lookup_timeout), regex_parser: RegexParser::new(regex_parser_limit, query_parser), identity: identity.clone(), + server_role, tls_client_certificate_required, schema_loader: Box::new(schema_loader::FromServer), canonical_oids, @@ -499,6 +505,12 @@ impl Cluster { self.identity.as_deref() } + /// PostgreSQL role backend connections impersonate through the `role` + /// startup parameter. + pub(crate) fn server_role(&self) -> Option<&str> { + self.server_role.as_deref() + } + /// This user must present a client TLS certificate when connecting over TLS. pub(crate) fn tls_client_certificate_required(&self) -> bool { self.tls_client_certificate_required @@ -603,6 +615,12 @@ impl Cluster { /// Use the query parser. pub(crate) fn use_query_parser(&self, request: &ClientRequest) -> bool { + // Every statement has to be inspected for role changes; the regex + // fast path would let `SELECT set_config('role', ...)` through. + if self.server_role.is_some() { + return true; + } + match self.query_parser() { QueryParserLevel::Off => false, QueryParserLevel::On => true, @@ -932,6 +950,11 @@ mod test { cluster } + /// Impersonate `role` on this cluster's backend connections. + pub(crate) fn set_server_role(&mut self, role: &str) { + self.server_role = Some(role.into()); + } + pub(crate) fn new_test_single_primary(config: &ConfigAndUsers) -> Cluster { let identifier = Arc::new(DatabaseUser { user: "pgdog".into(), @@ -1211,4 +1234,26 @@ mod test { cluster.query_parser = QueryParserLevel::Off; assert!(!cluster.use_query_parser(&req)); } + + #[test] + fn test_use_query_parser_server_role() { + // A plain SELECT never trips the regex fast path on its own. + let req = ClientRequest::from(vec![ + Query::new("SELECT set_config('role', 'postgres', false)").into(), + ]); + + let mut cluster = Cluster::new_test_single_primary(&config()); + cluster.query_parser = QueryParserLevel::Off; + assert!(!cluster.use_query_parser(&req)); + + cluster.set_server_role("analytics"); + for level in [ + QueryParserLevel::Off, + QueryParserLevel::SessionControl, + QueryParserLevel::Auto, + ] { + cluster.query_parser = level; + assert!(cluster.use_query_parser(&req), "{level:?}"); + } + } } diff --git a/pgdog/src/backend/pool/test/mod.rs b/pgdog/src/backend/pool/test/mod.rs index 070294977..4d89408a2 100644 --- a/pgdog/src/backend/pool/test/mod.rs +++ b/pgdog/src/backend/pool/test/mod.rs @@ -1289,3 +1289,61 @@ fn test_server_options_role() { .all(|p| p.name != "role") ); } + +/// A role a client escaped to must not survive on the pooled connection. +/// +/// The query parser rejects the statements that change `role`, but it cannot +/// see every spelling, and `RESET ALL` skips `role`, so the pool restores it +/// explicitly on check-in. `pgdog1` and `pgdog2` are created by +/// `integration/setup.sh`. +#[tokio::test] +async fn test_server_role_restored_on_checkin() { + crate::logger(); + + let pool = Pool::new(&PoolConfig { + address: Address { + server_role: Some("pgdog1".into()), + ..Address::new_test() + }, + config: Config { + max: 1, + min: 0, + ..Config::default() + }, + }); + pool.launch(); + + let id = { + let mut guard = pool.get(&Request::default()).await.unwrap(); + let role: Vec = guard.fetch_all("SELECT current_user").await.unwrap(); + assert_eq!(role[0], "pgdog1"); + + // Talk to the server directly, the way a `DO` block or a function body + // reaches `SET ROLE` without the parser seeing it. + guard.execute("SET ROLE pgdog2").await.unwrap(); + let role: Vec = guard.fetch_all("SELECT current_user").await.unwrap(); + assert_eq!(role[0], "pgdog2"); + + guard.id() + }; + + // Cleanup runs in a recovery task spawned when the guard is dropped. + for _ in 0..100 { + if pool.lock().idle() == 1 { + break; + } + sleep(Duration::from_millis(20)).await; + } + + let mut guard = pool.get(&Request::default()).await.unwrap(); + assert_eq!( + guard.id(), + id, + "connection was replaced, nothing was reused" + ); + let role: Vec = guard.fetch_all("SELECT current_user").await.unwrap(); + assert_eq!(role[0], "pgdog1", "escaped role leaked to the next session"); + + drop(guard); + pool.shutdown(); +} diff --git a/pgdog/src/frontend/client/query_engine/context.rs b/pgdog/src/frontend/client/query_engine/context.rs index 7f9af0cb1..c0aa10097 100644 --- a/pgdog/src/frontend/client/query_engine/context.rs +++ b/pgdog/src/frontend/client/query_engine/context.rs @@ -110,4 +110,15 @@ impl<'a> QueryEngineContext<'a> { pub(crate) fn in_error(&self) -> bool { self.transaction.map(|t| t.error()).unwrap_or_default() } + + /// Put an open transaction into the aborted state, like Postgres does + /// after an error: the client has to end it before running anything else. + pub(crate) fn abort_transaction(&mut self) { + self.transaction = self.transaction.map(|transaction| match transaction { + TransactionType::ReadOnly | TransactionType::ErrorReadOnly => { + TransactionType::ErrorReadOnly + } + _ => TransactionType::ErrorReadWrite, + }); + } } diff --git a/pgdog/src/frontend/client/query_engine/mod.rs b/pgdog/src/frontend/client/query_engine/mod.rs index 9196bcf8b..8e331b381 100644 --- a/pgdog/src/frontend/client/query_engine/mod.rs +++ b/pgdog/src/frontend/client/query_engine/mod.rs @@ -284,6 +284,12 @@ impl QueryEngine { Command::Discard { target, extended } => { self.discard(context, *target, *extended).await? } + Command::RoleLocked { name } => { + // Postgres aborts the transaction on this error too. + context.abort_transaction(); + self.error_response(context, ErrorResponse::role_locked(name)) + .await?; + } Command::Split(queries) => return Ok(Self::build_simple_split(queries)), } diff --git a/pgdog/src/frontend/router/parser/command.rs b/pgdog/src/frontend/router/parser/command.rs index adf96688b..911349178 100644 --- a/pgdog/src/frontend/router/parser/command.rs +++ b/pgdog/src/frontend/router/parser/command.rs @@ -64,6 +64,13 @@ pub(crate) 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 6ffde6ede..3a12d3682 100644 --- a/pgdog/src/frontend/router/parser/query/mod.rs +++ b/pgdog/src/frontend/router/parser/query/mod.rs @@ -340,6 +340,15 @@ impl QueryParser { .run()?; } + // Pools that impersonate a fixed `server_role` must not let clients + // change it. Checked before multi-statement handling so + // `SELECT 1; SET ROLE x` is caught as well. + if context.router_context.cluster.server_role().is_some() + && let Some(name) = set_config::role_escape_target(stmts) + { + return Ok(Command::RoleLocked { name }); + } + if let Some(command) = self.check_multi_query_statement(statement, context)? { return Ok(command); } diff --git a/pgdog/src/frontend/router/parser/query/set_config.rs b/pgdog/src/frontend/router/parser/query/set_config.rs index 7aad92b0b..89aadfa54 100644 --- a/pgdog/src/frontend/router/parser/query/set_config.rs +++ b/pgdog/src/frontend/router/parser/query/set_config.rs @@ -24,6 +24,38 @@ impl QueryParser { } } +const ROLE_ESCAPE_PARAMS: [&str; 2] = ["role", "session_authorization"]; + +/// Canonical name of a role-changing variable, matched case-insensitively. +fn role_escape_param(name: &str) -> Option<&'static str> { + ROLE_ESCAPE_PARAMS + .iter() + .copied() + .find(|param| name.eq_ignore_ascii_case(param)) +} + +/// Name of the first role-changing variable (`role`, `session_authorization`) +/// a statement list sets or resets, via `SET`/`RESET` or `set_config(...)`. +pub(super) fn role_escape_target(stmts: &pg_raw_parse::StmtList) -> Option { + for node in stmts.stmts() { + let name = match node { + Node::VariableSetStmt(stmt) => stmt.name().and_then(role_escape_param), + Node::SelectStmt(stmt) => extract_set_config(stmt) + .and_then(|fcall| fcall.args().first()) + .and_then(parse_config_name) + .as_deref() + .and_then(role_escape_param), + _ => None, + }; + + if let Some(name) = name { + return Some(name.to_string()); + } + } + + None +} + /// Returns None if the arguments could not be parsed fn parse_args(fcall: &nodes::FuncCall) -> Option { let name = parse_config_name(fcall.args().first()?)?; @@ -62,3 +94,53 @@ fn parse_is_local(arg: Node<'_>) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn role_escape(query: &str) -> Option { + let statements = pg_raw_parse::parse(query).expect("parse query"); + role_escape_target(&statements) + } + + #[test] + fn detects_role_escape_statements() { + for (query, expected) in [ + ("SET ROLE reporting", "role"), + ("SET LOCAL ROLE reporting", "role"), + ("RESET ROLE", "role"), + ( + "SET SESSION AUTHORIZATION reporting", + "session_authorization", + ), + ("RESET SESSION AUTHORIZATION", "session_authorization"), + ("SELECT 1; SET ROLE reporting", "role"), + ( + "SELECT set_config('role', 'report' || 'ing', false)", + "role", + ), + ( + "SELECT pg_catalog.set_config('ROLE', 'reporting', true)", + "role", + ), + ( + "SELECT set_config('session_authorization', current_user, false)", + "session_authorization", + ), + ] { + assert_eq!(role_escape(query).as_deref(), Some(expected), "{query}"); + } + } + + #[test] + fn allows_session_reset_to_startup_defaults() { + assert_eq!(role_escape("RESET ALL"), None); + assert_eq!(role_escape("DISCARD ALL"), None); + assert_eq!(role_escape("SET statement_timeout TO 1"), None); + assert_eq!( + role_escape("SELECT set_config('work_mem', '8MB', false)"), + None + ); + } +} diff --git a/pgdog/src/frontend/router/parser/query/test/mod.rs b/pgdog/src/frontend/router/parser/query/test/mod.rs index d16050f5a..043c421d4 100644 --- a/pgdog/src/frontend/router/parser/query/test/mod.rs +++ b/pgdog/src/frontend/router/parser/query/test/mod.rs @@ -35,6 +35,7 @@ pub(crate) mod test_rr; pub(crate) mod test_schema_sharding; pub(crate) mod test_search_path; pub(crate) mod test_select; +pub(crate) mod test_server_role; pub(crate) mod test_session_control; pub(crate) mod test_set; pub(crate) mod test_sharding; diff --git a/pgdog/src/frontend/router/parser/query/test/setup.rs b/pgdog/src/frontend/router/parser/query/test/setup.rs index e41a2de2c..4c9f4835a 100644 --- a/pgdog/src/frontend/router/parser/query/test/setup.rs +++ b/pgdog/src/frontend/router/parser/query/test/setup.rs @@ -89,6 +89,12 @@ impl QueryParserTest { me } + /// Impersonate a fixed role on the cluster's backend connections. + pub(crate) fn with_server_role(mut self, role: &str) -> Self { + self.cluster.set_server_role(role); + self + } + /// Set whether we're in a transaction. pub(crate) fn in_transaction(mut self, in_tx: bool) -> Self { self.transaction = if in_tx { diff --git a/pgdog/src/frontend/router/parser/query/test/test_server_role.rs b/pgdog/src/frontend/router/parser/query/test/test_server_role.rs new file mode 100644 index 000000000..ab9dc1179 --- /dev/null +++ b/pgdog/src/frontend/router/parser/query/test/test_server_role.rs @@ -0,0 +1,90 @@ +use pgdog_config::QueryParserLevel; + +use crate::{config::config, frontend::Command}; + +use super::setup::*; + +/// Pool impersonating a fixed role. The query parser is switched off to +/// check that `server_role` forces full parsing on its own. +fn setup() -> QueryParserTest { + let mut config = (*config()).clone(); + config.config.general.query_parser = QueryParserLevel::Off; + QueryParserTest::new_single_primary(&config).with_server_role("analytics") +} + +#[test] +fn test_rejects_role_changes() { + for (query, expected) in [ + ("SET ROLE postgres", "role"), + ("SET LOCAL ROLE postgres", "role"), + ("RESET ROLE", "role"), + ( + "SET SESSION AUTHORIZATION postgres", + "session_authorization", + ), + ("RESET SESSION AUTHORIZATION", "session_authorization"), + ("SELECT 1; SET ROLE postgres", "role"), + ("SET statement_timeout TO 1; RESET ROLE", "role"), + ("SELECT set_config('role', 'postgres', false)", "role"), + ( + "SELECT pg_catalog.set_config('role', 'postgres', true)", + "role", + ), + ( + "SELECT set_config('session_authorization', 'postgres', false)", + "session_authorization", + ), + ] { + let mut test = setup(); + let command = test.execute(vec![Query::new(query).into()]); + assert!( + matches!(&command, Command::RoleLocked { name } if name == expected), + "{query}: expected RoleLocked({expected}), got {command:#?}", + ); + } +} + +#[test] +fn test_rejects_role_changes_extended() { + let mut test = setup(); + let command = test.execute(vec![ + Parse::new_anonymous("SET ROLE postgres").into(), + Sync.into(), + ]); + assert!( + matches!(&command, Command::RoleLocked { name } if name == "role"), + "got {command:#?}", + ); +} + +#[test] +fn test_allows_other_session_state() { + for query in [ + "RESET ALL", + "DISCARD ALL", + "SET statement_timeout TO 1", + "RESET statement_timeout", + "SELECT set_config('work_mem', '8MB', false)", + "SELECT current_user", + ] { + let mut test = setup(); + let command = test.execute(vec![Query::new(query).into()]); + assert!( + !matches!(command, Command::RoleLocked { .. }), + "{query}: got {command:#?}", + ); + } +} + +#[test] +fn test_role_changes_allowed_without_server_role() { + let mut config = (*config()).clone(); + config.config.general.query_parser = QueryParserLevel::On; + let mut test = QueryParserTest::new_single_primary(&config); + + let command = test.execute(vec![Query::new("SET ROLE postgres").into()]); + assert!( + matches!(command, Command::Set { .. }), + "expected Command::Set, got {command:#?}", + ); +} diff --git a/pgdog/src/net/messages/error_response.rs b/pgdog/src/net/messages/error_response.rs index 526d541a4..cb7aba872 100644 --- a/pgdog/src/net/messages/error_response.rs +++ b/pgdog/src/net/messages/error_response.rs @@ -106,6 +106,19 @@ impl ErrorResponse { } } + pub(crate) 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(crate) fn omni_write_with_directive() -> ErrorResponse { ErrorResponse { severity: "ERROR".into(), From 84dafdeb0068a47a5acce411b5ae06e6ae939023 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:38:18 +0200 Subject: [PATCH 05/13] fix: ignore the role startup parameter on server_role pools Client startup parameters are synced to the server as SET statements on every checkout, and "role" is not in the untracked list. A client could therefore send role=other_role (or options=-c role=other_role) in its startup packet and have PgDog itself run SET "role" on the backend, bypassing the query-level guard; DISCARD ALL would re-apply it from the saved startup parameters. On pools with a fixed server_role the parameter is now dropped at login with a warning. Parameters::remove is added for this, since reset() has transaction semantics. Co-Authored-By: Claude Fable 5.1 --- pgdog/src/frontend/client/mod.rs | 32 +++++++++++++++++++++++++++++++- pgdog/src/net/parameter.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 9d35b194c..37c56ac16 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -243,10 +243,38 @@ impl Client { Ok(result) } + /// Drop the `role` startup parameter (`role=...` or `options=-c role=...`) + /// for pools that impersonate a fixed `server_role`. Left in place, it + /// would be synced to the server as `SET "role"` on every checkout and + /// bypass the query-level guard. + fn strip_startup_role(params: &mut Parameters, addr: SocketAddr) { + if params.get("role").is_none() { + return; + } + + let (user, database) = user_database_from_params(params); + let fixed_role = databases::databases() + .cluster((user, database)) + .map(|cluster| cluster.server_role().is_some()) + .unwrap_or(false); + + if !fixed_role { + return; + } + + let (user, database) = (user.to_owned(), database.to_owned()); + if let Some(role) = params.remove("role") { + warn!( + r#"user "{}" on database "{}" requested startup role {} on a pool with a fixed server_role, ignoring [{}]"#, + user, database, role, addr + ); + } + } + /// Create new frontend client from the given TCP stream. async fn login( mut stream: Stream, - params: Parameters, + mut params: Parameters, addr: SocketAddr, config: Arc, protocol_version: ProtocolVersion, @@ -257,6 +285,8 @@ impl Client { return Ok(None); } + Self::strip_startup_role(&mut params, addr); + let (user, database) = user_database_from_params(¶ms); let admin = database == config.config.admin.name && config.config.admin.user == user; let admin_password = &config.config.admin.password; diff --git a/pgdog/src/net/parameter.rs b/pgdog/src/net/parameter.rs index 1edec4bcb..fdc2c6c03 100644 --- a/pgdog/src/net/parameter.rs +++ b/pgdog/src/net/parameter.rs @@ -217,6 +217,18 @@ impl Parameters { result } + /// Remove a parameter for good (unlike [`Self::reset`], which is + /// transactional), returning its value. + pub(crate) fn remove(&mut self, name: &str) -> Option { + let result = self.params.remove(&name.to_lowercase()); + + if result.is_some() { + self.hash = Self::compute_hash(&self.params); + } + + result + } + /// Recompute hash when params are cleared. pub(crate) fn clear(&mut self) { self.params.clear(); @@ -1053,6 +1065,26 @@ mod test { ); } + #[test] + fn test_remove() { + let mut params = Parameters::default(); + params.insert("role", "postgres"); + params.insert("search_path", "public"); + let before = params.hash; + + assert_eq!( + params.remove("ROLE"), + Some(ParameterValue::String("postgres".into())) + ); + assert_eq!(params.get("role"), None); + assert_ne!(params.hash, before); + assert_eq!(params.remove("role"), None); + assert_eq!( + params.get("search_path"), + Some(&ParameterValue::String("public".into())) + ); + } + #[test] fn test_reset_all_rollback_restores_all() { let mut params = Parameters::default(); From d86f42eb90fe966c2f15ba1acb9761232aefe874 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 13:18:39 +0200 Subject: [PATCH 06/13] feat: delegate client authentication to plugins with auth_type plugin With auth_type = "plugin", PgDog asks the client for a cleartext credential (the same wire flow as passthrough auth) and hands it to the loaded plugins in [[plugins]] order on the Tokio blocking pool. The first plugin that does not skip decides. Deny sends the client the generic auth error and logs the reason only in PgDog; when every plugin skips the login is denied as well, there is no fallback to password verification. A plugin panic is caught by the pgdog-plugin bridge and becomes a Deny. An Allow may carry a derived user, backend credentials, a server_role, a read-only flag and a provision flag. databases::add_authenticated reconciles the grant into the pools on every Allow: it fills missing server_user, server_password, server_role and read_only on an existing users.toml entry, never overwrites a configured value (a configured server_role that conflicts with the grant wins and logs a warning), and provisions a new pool when the plugin asks for one that does not exist. Grants are validated first: the names in them become pool identities, config entries and startup parameters, so an empty, padded, over-long or control-character name (or such a server_password) denies the login with PluginInvalidGrant instead of provisioning a user PostgreSQL cannot address. Because a plugin can change which pool a login lands on, the startup "role" parameter check from the server_role work is repeated against the effective user after a plugin Allow; otherwise role=... in the startup packet would be synced to a freshly provisioned or completed impersonation pool. strip_startup_role now takes the user and database explicitly to allow this. Concurrency of plugin calls is bounded by the runtime's blocking pool, which general.background_workers already sizes; the branch's separate semaphore and duplicate setting are not carried over. Config load warns when auth_type = "plugin" runs without tls_client_required (the credential travels in plaintext), without any [[plugins]], or with background_workers = 0, where logins serialize on the one blocking thread that also resolves backend DNS. The "doesn't have a password" warning is silenced for plugin auth, where a user without a configured password is the normal case, and the connection line reports auth: plugin rather than auth: passthrough. The JSON schema is regenerated for the new variant. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) --- .schema/pgdog.schema.json | 5 + example.pgdog.toml | 10 + pgdog-config/src/auth.rs | 70 ++++++ pgdog-config/src/core.rs | 25 +++ pgdog-config/src/users.rs | 8 +- pgdog/src/auth/auth_result.rs | 13 ++ pgdog/src/auth/mod.rs | 1 + pgdog/src/auth/plugin.rs | 372 +++++++++++++++++++++++++++++++ pgdog/src/backend/databases.rs | 149 +++++++++++++ pgdog/src/frontend/client/mod.rs | 113 ++++++++-- 10 files changed, 752 insertions(+), 14 deletions(-) create mode 100644 pgdog/src/auth/plugin.rs diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index a8609bdc1..9b4b811ad 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -323,6 +323,11 @@ "description": "Plaintext password.", "type": "string", "const": "plain" + }, + { + "description": "Delegate client authentication to the loaded plugins through the\nauthenticate hook. The client sends its credential in plaintext and the\nfirst plugin that does not skip decides; when every plugin skips, the\nlogin is denied (there is no password fallback).", + "type": "string", + "const": "plugin" } ] }, diff --git a/example.pgdog.toml b/example.pgdog.toml index c1d52021e..296896abc 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -291,6 +291,16 @@ mirror_queue = 128 # - scram # - md5 # - trust +# - plain +# - plugin: delegate client authentication to the loaded plugins that implement +# the authenticate hook (see the [[plugins]] section). PgDog asks the client +# for a plaintext credential (so use TLS) and consults the plugins in +# [[plugins]] order; the first one that does not skip decides. When every +# plugin skips, the login is denied: there is no password fallback, so a +# capable plugin must be loaded. Plugin calls run on the Tokio blocking pool, +# whose size is bounded by `background_workers`; with the default of 0 that +# pool has a single thread and plugin calls run one at a time. +# auth_type = "plugin" auth_type = "scram" # Disable cross-shard queries. # diff --git a/pgdog-config/src/auth.rs b/pgdog-config/src/auth.rs index 2d938118b..d4910b8a2 100644 --- a/pgdog-config/src/auth.rs +++ b/pgdog-config/src/auth.rs @@ -49,6 +49,11 @@ pub enum AuthType { Trust, /// Plaintext password. Plain, + /// Delegate client authentication to the loaded plugins through the + /// authenticate hook. The client sends its credential in plaintext and the + /// first plugin that does not skip decides; when every plugin skips, the + /// login is denied (there is no password fallback). + Plugin, } impl Display for AuthType { @@ -58,6 +63,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 +80,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 +95,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/core.rs b/pgdog-config/src/core.rs index 010a4518e..d3baa643b 100644 --- a/pgdog-config/src/core.rs +++ b/pgdog-config/src/core.rs @@ -579,6 +579,31 @@ impl Config { _ => (), } + // Plugin authentication reads the client's credential in plaintext + // from the wire; only TLS protects it on the network. + if self.general.auth_type.plugin() { + if !self.general.tls_client_required { + warn!( + "consider setting \"tls_client_required\" while \"auth_type\" is \"plugin\": the client's credential travels in plaintext and only TLS protects it" + ); + } + + if self.plugins.is_empty() { + warn!( + "\"auth_type\" is \"plugin\" but no [[plugins]] are configured; every login will be denied" + ); + } + + // Plugins authenticate on the blocking pool, which is one single + // thread unless `background_workers` says otherwise, and DNS + // lookups for new server connections share it. + if self.general.background_workers == 0 { + warn!( + "\"auth_type\" is \"plugin\" and \"background_workers\" is 0, so PgDog runs one blocking thread: plugin logins run one at a time and a slow plugin also delays backend DNS lookups; raise \"background_workers\" to the number of concurrent logins you expect" + ); + } + } + if !self.general.two_phase_commit && self.rewrite.enabled { if self.rewrite.shard_key == RewriteMode::Rewrite { warn!( diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 1d18fb7c9..5d8451a12 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -52,7 +52,13 @@ impl Users { pub fn check(&mut self, config: &Config) { for user in &mut self.users { if user.passwords().is_empty() { - if !config.general.passthrough_auth() && user.identity.is_none() { + // Under `auth_type = "plugin"` a user without a password is + // the normal case: the plugin authenticates the client and + // PgDog never compares a configured password. + if !config.general.passthrough_auth() + && !config.general.auth_type.plugin() + && user.identity.is_none() + { warn!( r#"user "{}" (database "{}") doesn't have a password, passthrough auth and mTLS are disabled"#, user.name, user.database, diff --git a/pgdog/src/auth/auth_result.rs b/pgdog/src/auth/auth_result.rs index 0be7406e8..ac36f6dab 100644 --- a/pgdog/src/auth/auth_result.rs +++ b/pgdog/src/auth/auth_result.rs @@ -21,6 +21,14 @@ pub(crate) 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, + /// A plugin accepted the client but returned a grant PgDog cannot use, + /// e.g. an empty or over-long user name. + PluginInvalidGrant, } impl AuthResult { @@ -54,6 +62,11 @@ 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"), + Self::PluginInvalidGrant => { + write!(f, "authentication plugin returned an unusable grant") + } } } } diff --git a/pgdog/src/auth/mod.rs b/pgdog/src/auth/mod.rs index 0d5f99b7e..64656cb8d 100644 --- a/pgdog/src/auth/mod.rs +++ b/pgdog/src/auth/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod auth_result; pub(crate) mod error; pub(crate) mod md5; +pub(crate) mod plugin; pub(crate) mod scram; pub(crate) mod vault; diff --git a/pgdog/src/auth/plugin.rs b/pgdog/src/auth/plugin.rs new file mode 100644 index 000000000..f335503a5 --- /dev/null +++ b/pgdog/src/auth/plugin.rs @@ -0,0 +1,372 @@ +//! 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`](pgdog_plugin::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). Concurrency is bounded by the runtime's blocking pool, whose +//! size is the `background_workers` setting (`max_blocking_threads`); with the +//! default of `0` that pool has a single thread, so plugin calls run one at a +//! time. + +use pgdog_plugin::{AuthContext, AuthDecisionTag, AuthField, AuthGrant, PdStr}; +use tokio::task::spawn_blocking; +use tracing::{debug, warn}; + +use crate::auth::AuthResult; +use crate::config::config_quick; +use crate::plugin::plugins; + +/// Outcome of running the authentication plugins for a single client. +#[derive(Debug)] +pub(crate) struct PluginAuthOutcome { + /// Overall result: [`AuthResult::Ok`] on Allow, otherwise a plugin denial. + pub(crate) result: AuthResult, + /// Grant returned by the accepting plugin (only set on Allow). + pub(crate) grant: Option, +} + +impl PluginAuthOutcome { + fn allow(grant: AuthGrant) -> Self { + Self { + result: AuthResult::Ok, + grant: Some(grant), + } + } + + fn denied() -> Self { + Self { + result: AuthResult::PluginDenied, + grant: None, + } + } + + fn invalid_grant() -> Self { + Self { + result: AuthResult::PluginInvalidGrant, + grant: None, + } + } + + fn no_decision() -> Self { + Self { + result: AuthResult::PluginNoDecision, + grant: None, + } + } +} + +/// 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(crate) async fn authenticate( + user: String, + database: String, + credential: String, + client_addr: String, + tls_identity: Option, + tls: bool, +) -> PluginAuthOutcome { + let join = spawn_blocking(move || { + 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 +/// `[[plugins]]` configuration order, 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: outcome.read_only_flag(), + provision: outcome.provision, + }; + + if let Some(problem) = invalid_grant(&grant) { + warn!( + r#"client "{}" denied: plugin "{}" returned an unusable grant: {}"#, + user, name, problem + ); + return PluginAuthOutcome::invalid_grant(); + } + + debug!(r#"client "{}" authenticated by plugin "{}""#, user, name); + return PluginAuthOutcome::allow(grant); + } + AuthDecisionTag::Deny => { + // The reason is logged but never sent to the client. `warn!` + // follows `log_connections` like the other auth failures, so a + // client retrying in a loop cannot flood the log; the reason is + // always available at debug level. + let reason = collected.error.as_deref().unwrap_or("no reason given"); + if config_quick().config.general.log_connections { + warn!( + r#"client "{}" denied by plugin "{}": {}"#, + user, name, reason + ); + } else { + debug!( + r#"client "{}" denied by plugin "{}": {}"#, + user, name, reason + ); + } + return PluginAuthOutcome::denied(); + } + } + } + + // Every plugin skipped (or there were none). Deny: no password fallback. + PluginAuthOutcome::no_decision() +} + +/// PostgreSQL's identifier limit, `NAMEDATALEN - 1`. +const MAX_NAME_LENGTH: usize = 63; + +/// Why PgDog cannot use a name a plugin returned, if it cannot. +/// +/// These names become pool identities, `users.toml` entries and the `role` +/// startup parameter, so they are checked before anything acts on them: an +/// empty one would create a user called `""`, padding makes two identities +/// that look identical in the config file, a control character could be +/// smuggled into it or into the startup packet, and PostgreSQL truncates +/// identifiers past 63 bytes, which would quietly map different identities +/// onto one role. +fn invalid_name(value: &str) -> Option<&'static str> { + if value.is_empty() { + Some("is empty") + } else if value.trim() != value { + Some("has leading or trailing whitespace") + } else if value.len() > MAX_NAME_LENGTH { + Some("is longer than PostgreSQL's 63-byte limit") + } else if value.contains(char::is_control) { + Some("contains a control character") + } else { + None + } +} + +/// First problem with a grant, if any. A plugin that returns one is buggy, so +/// the login is denied rather than fixed up. +fn invalid_grant(grant: &AuthGrant) -> Option { + for (field, value) in [ + ("derived_user", &grant.derived_user), + ("server_user", &grant.server_user), + ("server_role", &grant.server_role), + ] { + if let Some(value) = value.as_deref() + && let Some(problem) = invalid_name(value) + { + return Some(format!("{field} {problem}")); + } + } + + // Not an identifier, but it is stored in users.toml and sent to the + // server, so it cannot be empty or carry control characters either. + if let Some(password) = grant.server_password.as_deref() + && (password.is_empty() || password.contains(char::is_control)) + { + return Some("server_password is empty or contains a control character".into()); + } + + None +} + +#[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.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); + } + + #[test] + fn test_invalid_grant_rejects_unusable_names() { + for (field, value, expected) in [ + ("derived_user", "", "derived_user is empty"), + ( + "derived_user", + " alice", + "derived_user has leading or trailing whitespace", + ), + ( + "server_user", + "svc\u{0}", + "server_user contains a control character", + ), + ( + "server_role", + "report\ning", + "server_role contains a control character", + ), + ] { + let mut grant = AuthGrant::default(); + let slot = match field { + "derived_user" => &mut grant.derived_user, + "server_user" => &mut grant.server_user, + _ => &mut grant.server_role, + }; + *slot = Some(value.into()); + + assert_eq!(invalid_grant(&grant).as_deref(), Some(expected)); + } + + let grant = AuthGrant { + derived_user: Some("a".repeat(MAX_NAME_LENGTH + 1)), + ..Default::default() + }; + assert_eq!( + invalid_grant(&grant).as_deref(), + Some("derived_user is longer than PostgreSQL's 63-byte limit") + ); + + let grant = AuthGrant { + server_password: Some(String::new()), + ..Default::default() + }; + assert!(invalid_grant(&grant).is_some()); + } + + #[test] + fn test_invalid_grant_accepts_a_usable_grant() { + let grant = AuthGrant { + derived_user: Some("alice@example.com".into()), + server_role: Some("alice@example.com".into()), + server_user: Some("pgdog".into()), + server_password: Some("hunter2".into()), + read_only: Some(true), + provision: true, + }; + assert_eq!(invalid_grant(&grant), None); + + // A grant that sets nothing at all is the common case: the client keeps + // the startup user and an existing pool. + assert_eq!(invalid_grant(&AuthGrant::default()), None); + + // 63 bytes is the limit, not one less. + let grant = AuthGrant { + derived_user: Some("a".repeat(MAX_NAME_LENGTH)), + ..Default::default() + }; + assert_eq!(invalid_grant(&grant), None); + } + + #[test] + fn test_outcome_constructors() { + let grant = AuthGrant { + derived_user: Some("reporting".into()), + provision: true, + ..Default::default() + }; + let allow = PluginAuthOutcome::allow(grant); + assert!(allow.result.is_ok()); + assert_eq!( + allow.grant.and_then(|grant| grant.derived_user).as_deref(), + Some("reporting") + ); + + let denied = PluginAuthOutcome::denied(); + 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/databases.rs b/pgdog/src/backend/databases.rs index 2ebf1a552..c5bf126c2 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -231,6 +231,82 @@ 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. +/// +/// The entry, including any `server_password` the plugin supplied, is stored in +/// the in-memory configuration only: `users.toml` on disk is never written, and +/// a configuration reload drops whatever was provisioned here, exactly like the +/// passthrough path in [`add`]. Names in the grant are validated by +/// [`crate::auth::plugin`] before they reach this function. +pub(crate) 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; + } else if let (Some(configured), Some(granted)) = (&existing.server_role, &user.server_role) + && configured != granted + { + // The plugin asked to impersonate a different role than the one + // configured; the configured value wins, so queries will not run + // as the authenticated identity. + warn!( + r#"user "{}" on database "{}": configured server_role "{}" overrides plugin-granted server_role "{}""#, + existing.name, existing.database, configured, granted + ); + } + 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. @@ -858,6 +934,79 @@ mod tests { assert_eq!(found.unwrap().password, Some("new_pass".to_string())); } + #[tokio::test] + async fn test_add_authenticated_fills_server_role_gap() { + setup_config( + PassthroughAuth::Disabled, + vec![ConfigUser { + name: "alice@example.com".to_string(), + database: "db1".to_string(), + server_user: Some("service".to_string()), + server_password: Some("secret".to_string()), + ..Default::default() + }], + ); + + let granted = ConfigUser { + name: "alice@example.com".to_string(), + database: "db1".to_string(), + server_role: Some("alice@example.com".to_string()), + ..Default::default() + }; + assert!( + add_authenticated(granted) + .expect("add_authenticated") + .is_ok() + ); + + let config = crate::config::config(); + let found = config + .users + .find(&make_user("alice@example.com", None)) + .expect("user exists"); + // The grant filled the missing role; configured credentials stayed. + assert_eq!(found.server_role.as_deref(), Some("alice@example.com")); + assert_eq!(found.server_user.as_deref(), Some("service")); + assert_eq!(found.server_password.as_deref(), Some("secret")); + + // The rebuilt pool impersonates the role via the startup packet. + let cluster = databases() + .cluster(("alice@example.com", "db1")) + .expect("cluster exists"); + assert_eq!(cluster.server_role(), Some("alice@example.com")); + } + + #[tokio::test] + async fn test_add_authenticated_keeps_configured_server_role() { + setup_config( + PassthroughAuth::Disabled, + vec![ConfigUser { + name: "bob".to_string(), + database: "db1".to_string(), + server_role: Some("analytics".to_string()), + ..Default::default() + }], + ); + + let granted = ConfigUser { + name: "bob".to_string(), + database: "db1".to_string(), + server_role: Some("bob".to_string()), + ..Default::default() + }; + assert!( + add_authenticated(granted) + .expect("add_authenticated") + .is_ok() + ); + + let config = crate::config::config(); + let found = config + .users + .find(&make_user("bob", None)) + .expect("user exists"); + assert_eq!(found.server_role.as_deref(), Some("analytics")); + } #[tokio::test] async fn test_add_existing_user_wrong_password_no_change_allowed() { setup_config( diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 37c56ac16..188f01ea8 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -15,7 +15,7 @@ use tracing::{Level as LogLevel, debug, enabled, error, info, trace, warn}; use super::{ClientRequest, Error, PreparedStatements}; use crate::auth::AuthResult; -use crate::auth::{md5, scram::Server}; +use crate::auth::{md5, plugin, scram::Server}; use crate::backend::maintenance_mode; use crate::backend::pool::stats::MemoryStats; use crate::backend::{ @@ -218,7 +218,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?; @@ -247,12 +250,11 @@ impl Client { /// for pools that impersonate a fixed `server_role`. Left in place, it /// would be synced to the server as `SET "role"` on every checkout and /// bypass the query-level guard. - fn strip_startup_role(params: &mut Parameters, addr: SocketAddr) { + fn strip_startup_role(params: &mut Parameters, user: &str, database: &str, addr: SocketAddr) { if params.get("role").is_none() { return; } - let (user, database) = user_database_from_params(params); let fixed_role = databases::databases() .cluster((user, database)) .map(|cluster| cluster.server_role().is_some()) @@ -262,7 +264,6 @@ impl Client { return; } - let (user, database) = (user.to_owned(), database.to_owned()); if let Some(role) = params.remove("role") { warn!( r#"user "{}" on database "{}" requested startup role {} on a pool with a fixed server_role, ignoring [{}]"#, @@ -285,9 +286,14 @@ impl Client { return Ok(None); } - Self::strip_startup_role(&mut params, addr); - let (user, database) = user_database_from_params(¶ms); + // Owned copies: `params` is edited below (startup `role` stripping) + // while these names stay in use. + let (user, database) = (user.to_owned(), database.to_owned()); + let (user, database) = (user.as_str(), database.as_str()); + + Self::strip_startup_role(&mut params, user, database, addr); + let admin = database == config.config.admin.name && config.config.admin.user == user; let admin_password = &config.config.admin.password; let auth_type = &config.config.general.auth_type; @@ -300,6 +306,10 @@ impl Client { // could never be satisfied. let client_ca_configured = config.config.general.tls_client_ca_certificate.is_some(); + // 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. // @@ -310,6 +320,62 @@ 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 = 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); + + // Reconcile the grant with the derived user's pool: + // fill backend-credential gaps (e.g. `server_role` + // for impersonation) on an existing entry, or + // provision a new pool when the plugin asked for + // it. Without a pool and without `provision`, + // `Connection::new` below fails the login. + let exists = databases::databases() + .cluster((effective, database)) + .is_ok(); + if exists || grant.provision { + let granted = 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(granted)?; + } + } + 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. @@ -363,19 +429,34 @@ 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). + 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?; } + // A plugin Allow may have derived another user or given the pool a + // `server_role` just now; the startup `role` check above ran against + // the original user, so repeat it against the pool actually used. + if auth_type.plugin() && !admin { + Self::strip_startup_role(&mut params, effective_user, database, addr); + } + // Check if the pooler is shutting down. // // We do this late because we don't want to give away anything about the @@ -387,11 +468,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); } }; @@ -407,7 +490,7 @@ impl Client { addr ); stream - .fatal(ErrorResponse::connection(user, database)) + .fatal(ErrorResponse::connection(effective_user, database)) .await?; return Ok(None); } else { @@ -430,7 +513,11 @@ impl Client { user, database, addr, - if passthrough { + // `auth_type = "plugin"` is what authenticated this client + // even when passthrough is also enabled for other users. + if auth_type.plugin() && !admin { + "plugin".into() + } else if passthrough { "passthrough".into() } else { auth_type.to_string() From 9ddd86af35e673957e8e2ace96c651aee6abb1d6 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 13:18:39 +0200 Subject: [PATCH 07/13] feat: launch pools that carry backend credentials under plugin auth Pools are disabled at launch when the user has no client password and no mTLS identity, because nobody could log in to them. Under auth_type = "plugin" a plugin vouches for every login instead, so such a pool is useful as long as it can authenticate to Postgres on its own. Cluster::has_backend_credentials reports whether any pool has a backend password or an external-identity server_auth. The launch gate lets a cluster through when plugin auth is on and that holds; pools with no credentials at all stay disabled until a plugin Allow provisions them through add_authenticated. Non-plugin auth types keep the previous behaviour unchanged. Co-Authored-By: Claude Fable 5.1 --- pgdog/src/backend/databases.rs | 11 ++++++++++- pgdog/src/backend/pool/cluster.rs | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pgdog/src/backend/databases.rs b/pgdog/src/backend/databases.rs index c5bf126c2..dd42fdc3e 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -533,8 +533,17 @@ impl Databases { } // Launch all clusters + let plugin_auth = config().config.general.auth_type.plugin(); for cluster in self.all().values() { - if cluster.passwords().is_empty() && cluster.identity().is_none() { + // A cluster needs a way for clients to authenticate to it and a way + // for it to authenticate to Postgres. Without a client password or + // an mTLS identity nobody can log in, unless plugin authentication + // is on: then a plugin vouches for every login and the pool only + // needs its own backend credentials (a server password or an + // external identity). A pool with neither stays disabled until a + // plugin Allow supplies credentials through `add_authenticated`. + let plugin_pool = plugin_auth && cluster.has_backend_credentials(); + if cluster.passwords().is_empty() && cluster.identity().is_none() && !plugin_pool { warn!( r#"disabling pool for user "{}" and database "{}", password not set"#, cluster.user(), diff --git a/pgdog/src/backend/pool/cluster.rs b/pgdog/src/backend/pool/cluster.rs index c68024356..41ec37faf 100644 --- a/pgdog/src/backend/pool/cluster.rs +++ b/pgdog/src/backend/pool/cluster.rs @@ -511,6 +511,16 @@ impl Cluster { self.server_role.as_deref() } + /// Whether this cluster's pools can authenticate to PostgreSQL on their + /// own: a backend password on any pool, or an external identity + /// (IAM, Vault, ...) as `server_auth`. + pub(crate) fn has_backend_credentials(&self) -> bool { + self.shards.iter().flat_map(Shard::pools).any(|pool| { + let addr = pool.addr(); + !addr.passwords.is_empty() || addr.server_auth.is_external_identity() + }) + } + /// This user must present a client TLS certificate when connecting over TLS. pub(crate) fn tls_client_certificate_required(&self) -> bool { self.tls_client_certificate_required From 36e9b4a34a3ed367fdcdf7b2c99dd6eac99ecee3 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 13:18:39 +0200 Subject: [PATCH 08/13] test: add authentication plugin integration suite A cdylib test plugin (integration/plugins/test-plugins/test-plugin-auth) decides from the credential the client sends: "deny", "panic", "secret-", "impersonate:" and Skip for anything else. The rspec suite in integration/plugins/auth covers the good login, the generic error for deny, panic, wrong and all-skip credentials (with the deny reason only in PgDog's log), pool provisioning with role impersonation, gap-filling server_role on a pre-configured pool, role persistence across connection cleanup, rejection of role escapes, and INSERT on a read-only pool. integration/plugins/run.sh builds the plugin and runs the suite as a second phase after the routing plugins; setup.sql creates the impersonated roles directly in Postgres. common.sh now captures stderr in integration/log.txt so the spec can assert on tracing output. Co-Authored-By: Claude Fable 5.1 --- integration/common.sh | 4 +- integration/plugins/auth/auth_spec.rb | 163 ++++++++++++++++++ integration/plugins/auth/pgdog.toml | 19 ++ integration/plugins/auth/setup.sql | 22 +++ integration/plugins/auth/users.toml | 34 ++++ integration/plugins/run.sh | 17 ++ .../test-plugins/test-plugin-auth/Cargo.toml | 12 ++ .../test-plugins/test-plugin-auth/src/lib.rs | 64 +++++++ 8 files changed, 334 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..d0aa7c5fa --- /dev/null +++ b/integration/plugins/auth/auth_spec.rb @@ -0,0 +1,163 @@ +# 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 + + # The connection line names the mechanism that actually authenticated the + # client, not passthrough. + expect(wait_for_log(/client "alice" connected.*auth: plugin/)).to be(true) + 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 'impersonates the derived role on a pre-configured pool' do + # `auditor` is configured in users.toml without `server_role`; the grant + # must fill it in so queries do not run as the `pgdog` service account. + conn = connect('auditor', 'impersonate:auditor') + expect(current_user(conn)).to eq('auditor') + 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 'denies a grant whose derived user PgDog cannot use' do + # "impersonate:" with nothing after it makes the plugin derive an empty + # user name, which would otherwise provision a pool (and a users.toml + # entry) called "". + expect { connect('alice', 'impersonate:') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + + expect(wait_for_log(/unusable grant: derived_user is empty/)).to be(true) + + # The rejected grant left nothing behind: the real login still works. + conn = connect('alice', 'secret-alice') + expect(current_user(conn)).to eq('pgdog') + 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..fa01f8e61 --- /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 and the connection lines the specs assert on are logged at +# warn!/info! only while this is on. +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..9a5dea25e --- /dev/null +++ b/integration/plugins/auth/setup.sql @@ -0,0 +1,22 @@ +-- 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; + +-- Role impersonated via `impersonate:auditor`. Unlike `reporting`, its pool is +-- pre-configured in users.toml (without `server_role`), so the plugin's grant +-- has to fill the gap rather than provision the pool. +DROP ROLE IF EXISTS auditor; +CREATE ROLE auditor NOLOGIN; +GRANT auditor 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..325daafd7 --- /dev/null +++ b/integration/plugins/auth/users.toml @@ -0,0 +1,34 @@ +# +# 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 + +# Pre-configured pool with no `server_role`: the plugin's grant must fill the +# gap so `impersonate:auditor` still runs queries as `auditor`, not as the +# `pgdog` service account. +[[users]] +name = "auditor" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" diff --git a/integration/plugins/run.sh b/integration/plugins/run.sh index 5d60df708..dc078adb7 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 roles, 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..9dca6333e --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test-plugin-auth" +version = "0.1.0" +edition = "2024" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pgdog-plugin = { path = "../../../../pgdog-plugin" } 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..d0d604225 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs @@ -0,0 +1,64 @@ +//! 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 `pgdog-plugin` bridge catches the +//! unwind inside the plugin and turns it into a Deny, 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 78c38b5460e35acda4d77ea590f5298b81ddc30e Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 13:37:51 +0200 Subject: [PATCH 09/13] feat: fall back to password auth when every auth plugin skips With auth_type = "plugin", a login where every plugin returned Skip was denied outright: the RFC decision was that the setting is explicit, so there is no password fallback. This changes that. When no plugin makes a decision, PgDog now verifies the cleartext credential the client already sent against the user's configured password (plain, or a SCRAM verifier through the new scram::verify_password), or hands it to passthrough authentication when passthrough is enabled and the user is not configured. An explicit Deny and a plugin task failure remain terminal, and a configured user with no client password at all is treated as plugin-only and is not eligible for the fallback. The motivation is that auth_type is a single global setting: a real deployment mixes humans (tokens checked by the plugin, which skips for non-email startup users) with service accounts that only have passwords. Without a fallback those services cannot log in at all unless a second PgDog is run for them. Verification against the configured password happens whether or not passthrough is enabled. Deferring to passthrough for a configured user would go through databases::add, which compares only the `password` field, so a user carrying just a `password_hash` and a `server_password` would have had the first credential that arrived accepted and stored. The cleartext check runs through maybe_spawn_blocking so SCRAM key derivation stays off the async runtime when background_workers are enabled, matching #1483 and #1539. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) --- pgdog/src/auth/auth_result.rs | 4 +- pgdog/src/auth/plugin.rs | 20 +-- pgdog/src/auth/scram/mod.rs | 69 ++++++++++ pgdog/src/frontend/client/mod.rs | 209 +++++++++++++++++++++++++------ 4 files changed, 252 insertions(+), 50 deletions(-) diff --git a/pgdog/src/auth/auth_result.rs b/pgdog/src/auth/auth_result.rs index ac36f6dab..8aa3ba9f9 100644 --- a/pgdog/src/auth/auth_result.rs +++ b/pgdog/src/auth/auth_result.rs @@ -23,8 +23,8 @@ pub(crate) enum AuthResult { 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. + /// No authentication plugin made a decision (all skipped). The frontend + /// uses this result to fall back to password or passthrough authentication. PluginNoDecision, /// A plugin accepted the client but returned a grant PgDog cannot use, /// e.g. an empty or over-long user name. diff --git a/pgdog/src/auth/plugin.rs b/pgdog/src/auth/plugin.rs index f335503a5..ea92d1233 100644 --- a/pgdog/src/auth/plugin.rs +++ b/pgdog/src/auth/plugin.rs @@ -4,9 +4,9 @@ //! for a cleartext password) and hands the credential to the loaded plugins. //! Each plugin answers with an [`AuthDecision`](pgdog_plugin::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). +//! If every plugin skips, the frontend falls back to the user's configured +//! password or to passthrough authentication (`Client::plugin_fallback`); an +//! explicit Deny and a plugin failure are terminal. //! //! Plugins run inside a single [`tokio::task::spawn_blocking`] call (they may //! block on I/O). Concurrency is bounded by the runtime's blocking pool, whose @@ -25,7 +25,9 @@ use crate::plugin::plugins; /// Outcome of running the authentication plugins for a single client. #[derive(Debug)] pub(crate) struct PluginAuthOutcome { - /// Overall result: [`AuthResult::Ok`] on Allow, otherwise a plugin denial. + /// Overall result: [`AuthResult::Ok`] on Allow, + /// [`AuthResult::PluginNoDecision`] when every plugin skipped, otherwise a + /// plugin denial. pub(crate) result: AuthResult, /// Grant returned by the accepting plugin (only set on Allow). pub(crate) grant: Option, @@ -90,7 +92,7 @@ pub(crate) async fn authenticate( Ok(outcome) => outcome, Err(err) => { warn!("authentication plugin task failed: {}", err); - PluginAuthOutcome::no_decision() + PluginAuthOutcome::denied() } } } @@ -193,7 +195,8 @@ fn run( } } - // Every plugin skipped (or there were none). Deny: no password fallback. + // Every plugin skipped (or there were none). Let the frontend apply its + // configured authentication fallback. PluginAuthOutcome::no_decision() } @@ -255,7 +258,8 @@ mod test { #[tokio::test] async fn test_all_skip_is_no_decision() { - // With no plugins loaded, the driver denies via PluginNoDecision. + // With no plugins loaded, the frontend receives PluginNoDecision and + // applies its configured authentication fallback. let outcome = authenticate( "alice".into(), "pgdog".into(), @@ -272,7 +276,7 @@ mod test { } #[test] - fn test_run_no_plugins_denies() { + fn test_run_no_plugins_returns_no_decision() { let outcome = run("bob", "pgdog", "secret", "127.0.0.1:5432", None, false); assert_eq!(outcome.result, AuthResult::PluginNoDecision); } diff --git a/pgdog/src/auth/scram/mod.rs b/pgdog/src/auth/scram/mod.rs index 85bd66cb8..b1af5f4b5 100644 --- a/pgdog/src/auth/scram/mod.rs +++ b/pgdog/src/auth/scram/mod.rs @@ -33,3 +33,72 @@ pub(crate) fn generate_hash( BASE64_STANDARD.encode(server_key.as_ref()), ) } + +/// Verify a plaintext password against a PostgreSQL SCRAM-SHA-256 verifier +/// (`SCRAM-SHA-256$iterations:salt$StoredKey:ServerKey`). +/// +/// Used when the client already sent its password in cleartext (the plugin +/// fallback, `auth_type = "plain"`) and a SCRAM exchange cannot be started on +/// the same connection. Malformed verifiers never match. This runs PBKDF2 and +/// is CPU-bound: callers on the async runtime should push it to a blocking +/// thread. +pub(crate) fn verify_password(password: &str, verifier: &str) -> bool { + use std::num::NonZeroU32; + + use base64::prelude::*; + + let Some(rest) = verifier.strip_prefix("SCRAM-SHA-256$") else { + return false; + }; + let Some((iterations_and_salt, _keys)) = rest.split_once('$') else { + return false; + }; + let Some((iterations, salt)) = iterations_and_salt.split_once(':') else { + return false; + }; + let Ok(iterations) = iterations.parse::() else { + return false; + }; + let Some(iterations) = NonZeroU32::new(iterations) else { + return false; + }; + let Ok(salt) = BASE64_STANDARD.decode(salt) else { + return false; + }; + + let candidate = generate_hash(password, iterations, &salt); + crate::util::constant_time_eq(candidate.as_bytes(), verifier.as_bytes()) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use super::{generate_hash, verify_password}; + + #[test] + fn verifies_plaintext_against_scram_verifier() { + let verifier = generate_hash( + "correct-password", + NonZeroU32::new(4096).expect("iterations are non-zero"), + b"pgdog_test_salt!", + ); + + assert!(verify_password("correct-password", &verifier)); + assert!(!verify_password("wrong-password", &verifier)); + } + + #[test] + fn rejects_invalid_scram_verifier() { + assert!(!verify_password("password", "not-a-scram-verifier")); + assert!(!verify_password( + "password", + "SCRAM-SHA-256$0:c2FsdA==$stored:server" + )); + // md5 verifiers are not SCRAM verifiers and never match. + assert!(!verify_password( + "password", + "md532b5f5d0e0a8c1a1b2c3d4e5f60718293" + )); + } +} diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 188f01ea8..0ef74d9a8 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -32,7 +32,7 @@ use crate::net::messages::{ use crate::net::{MessageBuffer, ProtocolMessage, Stream, parameter::Parameters}; use crate::state::State; use crate::stats::memory::MemoryUsage; -use crate::util::{safe_timeout, user_database_from_params}; +use crate::util::{maybe_spawn_blocking, safe_timeout, user_database_from_params}; pub(crate) mod query_engine; pub(crate) mod sticky; @@ -227,16 +227,15 @@ impl Client { .await?; let response = stream.read().await?; let response = Password::from_bytes(response.to_bytes())?; - let is_match = response.password().is_some_and(|provided| { - passwords.iter().any(|p| { - crate::util::constant_time_eq(p.as_str().as_bytes(), provided.as_bytes()) - }) - }); - - if is_match { - AuthResult::Ok - } else { - AuthResult::NoPasswordMatch + match response.password() { + Some(provided) => { + Self::check_cleartext_password_offloaded( + passwords.to_vec(), + provided.to_string(), + ) + .await? + } + None => AuthResult::NoPasswordMatch, } } @@ -246,6 +245,120 @@ impl Client { Ok(result) } + /// Verify a credential already received through a cleartext password + /// exchange. This is used when all authentication plugins skip, because a + /// second MD5 or SCRAM exchange cannot be started on the same connection. + fn check_cleartext_password(passwords: &[PasswordKind], provided: &str) -> AuthResult { + if passwords.is_empty() { + return AuthResult::NoPasswordConfig; + } + + let is_match = passwords.iter().any(|password| match password { + PasswordKind::Plain(password) => { + crate::util::constant_time_eq(password.as_bytes(), provided.as_bytes()) + } + PasswordKind::Hashed(verifier) => { + crate::auth::scram::verify_password(provided, verifier) + } + // Resolved to `Plain` by `vault::resolve_passwords` before we get + // here; an unresolved entry never matches. + PasswordKind::VaultStaticRole(_) => false, + }); + + if is_match { + AuthResult::Ok + } else { + AuthResult::NoPasswordMatch + } + } + + /// [`check_cleartext_password`](Self::check_cleartext_password), run off + /// the async runtime when `background_workers` are enabled: a SCRAM + /// verifier costs a PBKDF2 key derivation (see #1483). + async fn check_cleartext_password_offloaded( + passwords: Vec, + provided: String, + ) -> Result { + Ok( + maybe_spawn_blocking(move || Self::check_cleartext_password(&passwords, &provided)) + .await?, + ) + } + + /// Fall back after every authentication plugin returned `Skip`. + /// + /// A user present in `users.toml` is authenticated against its configured + /// password (plain or `password_hash`), checked in place rather than + /// through a second wire-protocol exchange, which cannot be started on a + /// connection that already answered a cleartext request. A configured user + /// with no client password at all is plugin-only: it is denied instead of + /// being handed to passthrough, which would accept and store whatever + /// credential arrived. Passthrough only applies to users that are not in + /// the configuration, exactly as it does without plugins. + /// + /// Note that this makes every configured password a second way in while + /// `auth_type = "plugin"` is set: a client whose credential no plugin + /// claims can still log in with it. Users that must only ever authenticate + /// through a plugin are configured without a client password. + async fn plugin_fallback( + stream: &Stream, + user: &str, + database: &str, + credential: &str, + passthrough: bool, + client_ca_configured: bool, + ) -> Result { + if let Ok(cluster) = databases::databases().cluster((user, database)) { + if let Some(identity) = cluster.identity() { + return Ok(if stream.tls_identity() == Some(identity) { + AuthResult::Ok + } else { + AuthResult::NoIdentity + }); + } + + if (ClientCertificateCheck { + client_ca_configured, + is_tls: stream.is_tls(), + required: cluster.tls_client_certificate_required(), + presented: stream.tls_client_certificate(), + }) + .rejected() + { + return Ok(AuthResult::NoClientCertificate); + } + + // A user that is in the configuration decides here, whatever + // passthrough would do with the credential. + // + // With no configured client password the user is plugin-only: + // deny, so passthrough cannot turn an account backed by service + // credentials into one that accepts its first arbitrary password. + // With one, verify against it: `databases::add` compares only the + // `password` field, so a user configured with `password_hash` + // alone would otherwise have any credential accepted and stored. + if cluster.passwords().is_empty() { + return Ok(AuthResult::NoPasswordConfig); + } + + let passwords = crate::auth::vault::resolve_passwords(cluster.passwords()).await; + return Self::check_cleartext_password_offloaded(passwords, credential.to_string()) + .await; + } else if !passthrough { + return Ok(AuthResult::NoUserOrDatabase); + } + + // Same call as the passthrough branch of `login`: the credential is + // stored and Postgres verifies it on the first server connection. + let user = config::User { + name: user.to_string(), + database: database.to_string(), + password: Some(credential.to_string()), + ..Default::default() + }; + Ok(databases::add(user)?) + } + /// Drop the `role` startup parameter (`role=...` or `options=-c role=...`) /// for pools that impersonate a fixed `server_role`. Left in place, it /// would be synced to the server as `SET "role"` on every checkout and @@ -324,7 +437,8 @@ impl Client { // 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. + // pool; Deny rejects the client; all-Skip falls back to configured + // password or passthrough authentication. stream .send_flush(&Authentication::ClearTextPassword) .await?; @@ -342,36 +456,51 @@ impl Client { ) .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); - - // Reconcile the grant with the derived user's pool: - // fill backend-credential gaps (e.g. `server_role` - // for impersonation) on an existing entry, or - // provision a new pool when the plugin asked for - // it. Without a pool and without `provision`, - // `Connection::new` below fails the login. - let exists = databases::databases() - .cluster((effective, database)) - .is_ok(); - if exists || grant.provision { - let granted = 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(granted)?; + match outcome.result { + AuthResult::Ok => { + if let Some(grant) = outcome.grant { + derived_user = grant.derived_user.clone(); + let effective = derived_user.as_deref().unwrap_or(user); + + // Reconcile the grant with the derived user's pool: + // fill backend-credential gaps (e.g. `server_role` + // for impersonation) on an existing entry, or + // provision a new pool when the plugin asked for + // it. Without a pool and without `provision`, + // `Connection::new` below fails the login. + let exists = databases::databases() + .cluster((effective, database)) + .is_ok(); + if exists || grant.provision { + let granted = 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(granted)?; + } } + AuthResult::Ok } - AuthResult::Ok - } else { - outcome.result + // Every plugin skipped: verify the credential against the + // configured password, or use passthrough authentication. + AuthResult::PluginNoDecision => { + Self::plugin_fallback( + &stream, + user, + database, + credential, + passthrough, + client_ca_configured, + ) + .await? + } + // Deny and plugin failures are terminal. + result => result, } } else { AuthResult::NoPasswordMessage From c8890d9b7de37ebd778d4769b1558acf93439819 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 13:37:51 +0200 Subject: [PATCH 10/13] test: cover the plugin skip fallback and document it Unit tests for check_cleartext_password and the fallback paths (configured password accepted, wrong password rejected, passthrough, plugin-only user not bootstrapped), a fallback example in the integration auth suite (alice gets a client password and the all-skip example now expects the password check to fail instead of a bare denial), and the auth_type docs in pgdog-config, example.pgdog.toml and the test plugin now describe the fallback instead of the all-skip denial. Co-Authored-By: Claude Fable 5.1 --- example.pgdog.toml | 12 +- integration/plugins/auth/auth_spec.rb | 8 +- integration/plugins/auth/pgdog.toml | 4 +- integration/plugins/auth/users.toml | 8 +- .../test-plugins/test-plugin-auth/src/lib.rs | 3 +- pgdog-config/src/auth.rs | 7 +- pgdog/src/frontend/client/test/auth.rs | 174 +++++++++++++++++- 7 files changed, 202 insertions(+), 14 deletions(-) diff --git a/example.pgdog.toml b/example.pgdog.toml index 296896abc..126752bed 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -296,10 +296,14 @@ mirror_queue = 128 # the authenticate hook (see the [[plugins]] section). PgDog asks the client # for a plaintext credential (so use TLS) and consults the plugins in # [[plugins]] order; the first one that does not skip decides. When every -# plugin skips, the login is denied: there is no password fallback, so a -# capable plugin must be loaded. Plugin calls run on the Tokio blocking pool, -# whose size is bounded by `background_workers`; with the default of 0 that -# pool has a single thread and plugin calls run one at a time. +# plugin skips, PgDog checks the credential against the user's configured +# password, or uses passthrough authentication for users that are not in +# users.toml; a configured user without a client password is plugin-only and +# is denied. An explicit deny is final. A configured password stays usable +# for login, so omit it for users that must only authenticate through a +# plugin. Plugin calls run on the Tokio blocking pool, whose size is +# bounded by `background_workers`; with the default of 0 that pool has a +# single thread and plugin calls run one at a time. # auth_type = "plugin" auth_type = "scram" # Disable cross-shard queries. diff --git a/integration/plugins/auth/auth_spec.rb b/integration/plugins/auth/auth_spec.rb index d0aa7c5fa..3dd606292 100644 --- a/integration/plugins/auth/auth_spec.rb +++ b/integration/plugins/auth/auth_spec.rb @@ -43,6 +43,12 @@ def wait_for_log(pattern, timeout: 5.0) expect(wait_for_log(/client "alice" connected.*auth: plugin/)).to be(true) end + it 'falls back to the configured password when every plugin skips' do + conn = connect('alice', 'postgres-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) @@ -75,7 +81,7 @@ def wait_for_log(pattern, timeout: 5.0) conn.close end - it 'denies an unknown credential when every plugin skips' do + it 'rejects an unknown credential when plugin and password authentication fail' do expect { connect('alice', 'no-such-credential') } .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) end diff --git a/integration/plugins/auth/pgdog.toml b/integration/plugins/auth/pgdog.toml index fa01f8e61..63734a6e9 100644 --- a/integration/plugins/auth/pgdog.toml +++ b/integration/plugins/auth/pgdog.toml @@ -1,8 +1,8 @@ # # Authentication-plugin integration suite. # -# `auth_type = "plugin"` routes every non-admin login through the loaded -# authentication plugins. `test_plugin_auth` (see +# `auth_type = "plugin"` tries the loaded authentication plugins before +# configured password authentication. `test_plugin_auth` (see # test-plugins/test-plugin-auth) makes the decisions the specs assert on. # [general] diff --git a/integration/plugins/auth/users.toml b/integration/plugins/auth/users.toml index 325daafd7..938ced113 100644 --- a/integration/plugins/auth/users.toml +++ b/integration/plugins/auth/users.toml @@ -1,10 +1,9 @@ # # 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. +# Plugin-authenticated clients use `server_user`/`server_password` for the +# backend pool. The `alice` entry also has a client password to exercise the +# fallback used when every plugin skips. # # Impersonation users (e.g. `impersonate:reporting`) are not listed here: the # plugin derives them and PgDog auto-provisions their pools. @@ -13,6 +12,7 @@ [[users]] name = "alice" database = "pgdog" +password = "postgres-alice" server_user = "pgdog" server_password = "pgdog" diff --git a/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs index d0d604225..9a89fceed 100644 --- a/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs +++ b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs @@ -12,7 +12,8 @@ //! 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). +//! - anything else => [`AuthDecision::Skip`] so PgDog tries its +//! configured password or passthrough authentication. use pgdog_plugin::{AuthContext, AuthDecision, AuthGrant, PdStr, Plugin, plugin}; diff --git a/pgdog-config/src/auth.rs b/pgdog-config/src/auth.rs index d4910b8a2..19b88ffb4 100644 --- a/pgdog-config/src/auth.rs +++ b/pgdog-config/src/auth.rs @@ -52,7 +52,12 @@ pub enum AuthType { /// Delegate client authentication to the loaded plugins through the /// authenticate hook. The client sends its credential in plaintext and the /// first plugin that does not skip decides; when every plugin skips, the - /// login is denied (there is no password fallback). + /// credential is checked against the user's configured password, or + /// passthrough authentication is used for users that are not configured. + /// An explicit deny is final. + /// + /// A configured password therefore remains a way in: leave it out of + /// `users.toml` for users that must only authenticate through a plugin. Plugin, } diff --git a/pgdog/src/frontend/client/test/auth.rs b/pgdog/src/frontend/client/test/auth.rs index f973b232a..a9bcdf957 100644 --- a/pgdog/src/frontend/client/test/auth.rs +++ b/pgdog/src/frontend/client/test/auth.rs @@ -1,12 +1,15 @@ //! Client authentication tests. -use pgdog_config::{AuthType, PassthroughAuth}; +use std::num::NonZeroU32; use crate::{ + auth::scram, config::{config, set}, expect_message, + frontend::Client, net::{Authentication, ErrorResponse, Parameters, Password}, }; +use pgdog_config::{AuthType, PassthroughAuth, users::PasswordKind}; use super::SpawnedClient; @@ -29,6 +32,20 @@ async fn login_admin(password: &str) -> SpawnedClient { client } +/// Connect to a regular database user and answer the cleartext credential +/// request used by plugin authentication. +async fn login_user(user: &str, password: &str) -> SpawnedClient { + let mut params = Parameters::default(); + params.insert("user", user); + params.insert("database", "pgdog"); + + let mut client = SpawnedClient::new_with_login(params).await; + let request = expect_message!(client.read().await, Authentication); + assert!(matches!(request, Authentication::ClearTextPassword)); + client.send(Password::new_password(password)).await; + client +} + /// Admin connections must be authenticated against the admin password even /// when passthrough auth is enabled. Regression test for the passthrough /// branch running first and accepting any password for the admin database. @@ -56,3 +73,158 @@ async fn test_admin_password_checked_with_passthrough_auth() { client.read_until('Z').await; client.join().await; } + +#[test] +fn test_cleartext_password_supports_plain_and_scram_verifiers() { + let verifier = scram::generate_hash( + "hashed-password", + NonZeroU32::new(4096).expect("iterations are non-zero"), + b"pgdog_test_salt!", + ); + let passwords = [ + PasswordKind::Plain("plain-password".into()), + PasswordKind::Hashed(verifier), + ]; + + assert_eq!( + Client::check_cleartext_password(&passwords, "plain-password"), + crate::auth::AuthResult::Ok + ); + assert_eq!( + Client::check_cleartext_password(&passwords, "hashed-password"), + crate::auth::AuthResult::Ok + ); + assert_eq!( + Client::check_cleartext_password(&passwords, "wrong-password"), + crate::auth::AuthResult::NoPasswordMatch + ); + assert_eq!( + Client::check_cleartext_password(&[], "anything"), + crate::auth::AuthResult::NoPasswordConfig + ); +} + +#[tokio::test] +async fn test_plugin_skip_falls_back_to_configured_password() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + set(cfg).unwrap(); + + let mut client = login_user("pgdog", "pgdog").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; +} + +#[tokio::test] +async fn test_plugin_skip_rejects_wrong_configured_password() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + set(cfg).unwrap(); + + let mut client = login_user("pgdog", "wrong-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; +} + +/// A configured user keeps being authenticated against its own password when +/// passthrough is enabled; passthrough does not take over the login. +#[tokio::test] +async fn test_plugin_skip_checks_configured_password_with_passthrough_enabled() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + set(cfg).unwrap(); + + let mut client = login_user("pgdog", "pgdog").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; + + let mut client = login_user("pgdog", "not-the-configured-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; +} + +/// Passthrough still applies to users that are not in the configuration: +/// `pgdog1` is created by `integration/setup.sh` and is not in the test config. +#[tokio::test] +async fn test_plugin_skip_falls_back_to_passthrough_for_unconfigured_user() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + set(cfg).unwrap(); + + let mut client = login_user("pgdog1", "pgdog").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; +} + +/// `databases::add` only ever compares the `password` field, so a user +/// configured with `password_hash` alone used to have any credential accepted +/// (and stored) once passthrough was enabled. The fallback verifies against +/// the configured password itself instead of deferring to passthrough. +#[tokio::test] +async fn test_plugin_skip_verifies_hashed_password_under_passthrough() { + crate::logger(); + crate::config::load_test(); + + let verifier = scram::generate_hash( + "hashed-password", + NonZeroU32::new(4096).expect("iterations are non-zero"), + b"pgdog_test_salt!", + ); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + cfg.users.users[0].password = None; + cfg.users.users[0].password_hash = Some(verifier); + cfg.users.users[0].server_password = Some("pgdog".into()); + set(cfg).unwrap(); + crate::backend::databases::reload_from_existing().unwrap(); + + let mut client = login_user("pgdog", "arbitrary-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; + + let mut client = login_user("pgdog", "hashed-password").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; +} + +#[tokio::test] +async fn test_plugin_skip_does_not_bootstrap_password_for_plugin_only_user() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + cfg.users.users[0].password = None; + cfg.users.users[0].server_password = Some("pgdog".into()); + set(cfg).unwrap(); + crate::backend::databases::reload_from_existing().unwrap(); + + let mut client = login_user("pgdog", "arbitrary-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; +} From 153554d5979754675ea380d5862d8543f2911085 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:21:22 +0200 Subject: [PATCH 11/13] feat: add Google access-token authentication plugin Add plugins/pgdog-google-auth, a PgDog authentication plugin that lets PostgreSQL clients log in with a Google OAuth 2.0 access token, such as the one printed by `gcloud auth print-access-token`, in place of a password. The plugin implements the `Plugin::authenticate` hook from pgdog-plugin 0.5.0. It POSTs the presented token to Google's HTTPS tokeninfo endpoint (a form body, not a query string, so the token does not reach access logs on the way) with redirects disabled and a bounded timeout, then checks expiry, verified email, the OAuth audience, and optional account, domain and scope allowlists. `allowed_audiences` is required: Google introspects any valid access token and reports its owner's verified email, so without an audience check a token a user granted to an unrelated application would log that user into the database. The "aud"/"azp" claim is what ties a token to the deployment's own OAuth client, and there is no safe default, so the plugin refuses to load without one. On success the verified Google identity (email or user id) becomes the PostgreSQL user, is set as `server_role` for impersonation by default, and can optionally auto-provision a pool that connects with a shared service account whose password is read from an environment variable rather than the config file. Startup users that cannot belong to the Google email namespace return Skip so PgDog can fall back to password authentication; claimed users with a bad token are denied so they cannot downgrade. `expires_in` parses from either a JSON number or a decimal string, since Google's tokeninfo stringifies it and other identity endpoints do not, and a value that is neither is a response error rather than a panic. The startup user is compared to the derived identity case-insensitively, matching how `claims_user` decides whether to handle the login at all, so `Alice@Example.com` is no longer claimed and then denied. An identity carrying whitespace or control characters is refused before it can become a role name. Register the crate as a workspace member and document it in plugins/README.md. Cargo.lock only gains the new package entry and the `blocking`/`form` reqwest feature dependencies, which were already locked. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 19 + Cargo.toml | 4 +- plugins/README.md | 9 + plugins/pgdog-google-auth/Cargo.toml | 24 + plugins/pgdog-google-auth/README.md | 156 ++++ plugins/pgdog-google-auth/config.example.toml | 40 ++ plugins/pgdog-google-auth/src/config.rs | 410 +++++++++++ plugins/pgdog-google-auth/src/lib.rs | 56 ++ plugins/pgdog-google-auth/src/token_info.rs | 667 ++++++++++++++++++ 9 files changed, 1384 insertions(+), 1 deletion(-) create mode 100644 plugins/pgdog-google-auth/Cargo.toml create mode 100644 plugins/pgdog-google-auth/README.md create mode 100644 plugins/pgdog-google-auth/config.example.toml create mode 100644 plugins/pgdog-google-auth/src/config.rs create mode 100644 plugins/pgdog-google-auth/src/lib.rs create mode 100644 plugins/pgdog-google-auth/src/token_info.rs diff --git a/Cargo.lock b/Cargo.lock index 59aea83b0..ab4b5433c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3213,6 +3213,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "pgdog-google-auth" +version = "0.1.0" +dependencies = [ + "once_cell", + "parking_lot", + "pgdog-plugin", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "tracing", + "url", +] + [[package]] name = "pgdog-jsonschema" version = "0.1.0" @@ -3764,6 +3781,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -3784,6 +3802,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index af3efd1ce..0ed67210f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,9 @@ members = [ "pgdog-postgres-types", "pgdog-stats", "pgdog-vector", - "plugins/pgdog-example-plugin", "plugins/pgdog-primary-only-tables", + "plugins/pgdog-example-plugin", + "plugins/pgdog-google-auth", + "plugins/pgdog-primary-only-tables", "scripts/*", ] diff --git a/plugins/README.md b/plugins/README.md index 733ccec34..5865a8976 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,6 +4,15 @@ This directory contains plugins that ship with PgDog and are built by original a ## Plugins +### `pgdog-google-auth` + +Authenticates PostgreSQL clients with Google OAuth 2.0 access tokens, including +tokens printed by `gcloud auth print-access-token`. The plugin validates tokens +with Google's `tokeninfo` endpoint and can restrict access by Google account, +domain, OAuth audience, and scope. + +See the [`pgdog-google-auth` documentation](pgdog-google-auth/README.md). + ### `pgdog-example-plugin` Example plugin that can be used as reference by the community. It currently records diff --git a/plugins/pgdog-google-auth/Cargo.toml b/plugins/pgdog-google-auth/Cargo.toml new file mode 100644 index 000000000..33ce6e841 --- /dev/null +++ b/plugins/pgdog-google-auth/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pgdog-google-auth" +version = "0.1.0" +edition.workspace = true +description = "PgDog authentication plugin for Google OAuth 2.0 access tokens." +license = "AGPL-3.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +once_cell = "1" +parking_lot.workspace = true +pgdog-plugin.workspace = true +reqwest = { workspace = true, features = ["blocking", "form", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json.workspace = true +thiserror = "2" +toml = "0.8" +tracing = "0.1" +url = "2" + +[dev-dependencies] +tempfile = "3.23" diff --git a/plugins/pgdog-google-auth/README.md b/plugins/pgdog-google-auth/README.md new file mode 100644 index 000000000..5edc629ac --- /dev/null +++ b/plugins/pgdog-google-auth/README.md @@ -0,0 +1,156 @@ +# Google access-token authentication + +`pgdog-google-auth` lets PostgreSQL clients authenticate to PgDog with Google +OAuth 2.0 access tokens, including tokens produced by +[`gcloud auth print-access-token`](https://docs.cloud.google.com/sdk/gcloud/reference/auth/print-access-token). + +The plugin sends the token to Google's HTTPS `tokeninfo` endpoint. It checks the +token expiry and can enforce verified email, account, domain, OAuth audience, +and scope restrictions. The token is never forwarded to PostgreSQL. + +> [!WARNING] +> Access tokens are bearer credentials. Require TLS between clients and PgDog. +> The plugin enforces TLS by default. + +## Build + +The main PgDog container build includes the plugin at +`/usr/lib/libpgdog_google_auth.so`. To build it locally: + +```bash +cargo build --release -p pgdog-google-auth +``` + +## Configure PgDog + +Enable plugin authentication and point PgDog at the plugin configuration: + +```toml +[general] +auth_type = "plugin" +tls_client_required = true + +[[plugins]] +name = "pgdog_google_auth" +config = "google-auth.toml" +``` + +Start from [`config.example.toml`](config.example.toml). Two settings are not +optional: + +- `allowed_audiences` — the OAuth client IDs whose access tokens may log in. + The plugin refuses to load without it. Google will introspect *any* valid + access token, including one a user granted to an unrelated application, and + report their verified email; without an audience check that token logs them + into your database. The `aud`/`azp` claim is what ties a token to your own + client, so list the client IDs you issue tokens from. For the tokens + `gcloud auth print-access-token` mints, that is the gcloud client ID: + + ```bash + # The "aud" of a token the CLI issues. + curl -s -d "access_token=$(gcloud auth print-access-token)" \ + https://oauth2.googleapis.com/tokeninfo | jq -r .aud + ``` + +- `allowed_domains` or `allowed_emails` — which verified identities may + authenticate at all. + +To let selected users authenticate with PostgreSQL passwords, enable PgDog's +password or passthrough fallback: + +```toml +# pgdog.toml +[general] +auth_type = "plugin" +passthrough_auth = "enabled" +tls_client_required = true +``` + +With `username_claim = "email"`, `strip_email_domain = false`, and +`require_user_match = true`, the plugin claims only full email startup users +matching `allowed_domains` or `allowed_emails`. A non-email user such as +`postgres` returns `Skip` without sending its password to Google. Invalid Google +tokens for claimed email users still return `Deny`, so they cannot downgrade to +password authentication. For true backend passthrough, do not define the +non-email user in `users.toml`. + +When `require_user_match = false`, `strip_email_domain = true`, or +`username_claim = "user_id"`, PgDog cannot route by the startup email namespace. +The plugin therefore claims the credential and preserves fail-closed behavior. + +By default, the verified Google email becomes the PostgreSQL user and must +match the startup user, compared case-insensitively. For example: + +```bash +account="$(gcloud config get-value account)" +PGPASSWORD="$(gcloud auth print-access-token)" \ + psql "host=pgdog.example.com port=6432 dbname=app user=${account} sslmode=require" +``` + +For preconfigured pools, add the derived user to `users.toml`: + +```toml +[[users]] +name = "alice@example.com" +database = "app" +server_user = "pgdog_service" +# Render server_password here from the approved secret manager. +``` + +With `impersonate = true` (the default), the plugin's grant fills in +`server_role` at login with the derived Google identity, so queries run as +`alice@example.com` rather than as `pgdog_service`. The PostgreSQL role must +already exist and be granted to the service account (see below); otherwise the +backend refuses the `role` startup parameter and the login fails. An explicit +`server_role` in `users.toml` takes precedence over the grant; PgDog logs a +warning when they differ. + +Do not commit backend passwords. Render `users.toml` from the approved secret +manager or use auto-provisioning with `server_password_env`. + +## Auto-provision users + +The plugin can create a pool after it validates a token: + +```toml +allowed_domains = ["example.com"] +provision = true +impersonate = true +server_user = "pgdog_service" +server_password_env = "PGDOG_GOOGLE_AUTH_SERVER_PASSWORD" +``` + +Inject `PGDOG_GOOGLE_AUTH_SERVER_PASSWORD` into the PgDog process from the +approved secret manager. PgDog uses `server_user` to connect to PostgreSQL and +sets the derived Google identity as `server_role`. + +The PostgreSQL role must already exist, and the service account must be allowed +to assume it: + +```sql +GRANT "alice@example.com" TO pgdog_service; +``` + +Set `strip_email_domain = true` if PostgreSQL roles use the email local part. +Set `username_claim = "user_id"` if stable numeric Google account IDs are +preferred. + +## Security and operations + +- The token is sent only to the configured `tokeninfo_url`, as a POST form + body rather than a query string, so it does not appear in access logs along + the way. Redirects are disabled to prevent credential forwarding. +- `tokeninfo_url` must use HTTPS. Plain HTTP is accepted only for loopback test + servers. +- Request errors are sanitized so logs do not include the token-bearing URL. +- `allowed_audiences` is mandatory; see [Configure PgDog](#configure-pgdog). + Tokens minted for any other OAuth client are rejected. +- `timeout_ms` bounds token validation. PgDog's `background_workers` setting + caps concurrent blocking authentication calls; with its default of 0 there is + a single blocking thread, so logins wait behind each other and a slow + introspection call also delays backend DNS lookups. Raise it to the number of + concurrent logins you expect. +- Google may rate-limit token introspection. Connection pooling reduces calls + because validation happens once per client connection. +- A Google or network outage prevents new logins. Existing database sessions + remain active. diff --git a/plugins/pgdog-google-auth/config.example.toml b/plugins/pgdog-google-auth/config.example.toml new file mode 100644 index 000000000..438d53742 --- /dev/null +++ b/plugins/pgdog-google-auth/config.example.toml @@ -0,0 +1,40 @@ +# Google OAuth 2.0 token validation endpoint. +tokeninfo_url = "https://oauth2.googleapis.com/tokeninfo" +timeout_ms = 5000 + +# Access tokens are credentials. Keep this enabled outside isolated loopback +# tests so the PostgreSQL password exchange is encrypted. +require_tls = true + +# Derive the PostgreSQL user from "email" or "user_id". +username_claim = "email" +strip_email_domain = false +require_user_match = true +require_verified_email = true + +# Required: OAuth client IDs ("aud"/"azp") whose access tokens may log in. +# The plugin refuses to start without this. Google introspects any valid access +# token, so without it a token a user granted to an unrelated application +# authenticates that user against this database. +allowed_audiences = ["1234567890-abcdef.apps.googleusercontent.com"] + +# Restrict which Google identities may authenticate. If both lists are set, +# matching either an exact email or a domain is sufficient. +allowed_domains = ["example.com"] +allowed_emails = [] + +# With the defaults above, startup users outside the allowed full-email +# namespace (for example, "postgres") return Skip without sending their +# credential to Google. PgDog can then use password or passthrough fallback. + +# Optional scope restriction. +required_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + +# Existing users.toml entries do not need provisioning. To create pools for +# validated Google identities, enable provision and inject the backend password +# into PgDog's environment from the approved secret manager. +provision = false +impersonate = true +# server_user = "pgdog_service" +# server_password_env = "PGDOG_GOOGLE_AUTH_SERVER_PASSWORD" +# read_only = false diff --git a/plugins/pgdog-google-auth/src/config.rs b/plugins/pgdog-google-auth/src/config.rs new file mode 100644 index 000000000..5afce7e82 --- /dev/null +++ b/plugins/pgdog-google-auth/src/config.rs @@ -0,0 +1,410 @@ +use std::{env, fs, path::Path, time::Duration}; + +use reqwest::{Url, blocking::Client, redirect::Policy}; +use serde::Deserialize; +use thiserror::Error; +use url::Host; + +const DEFAULT_TOKENINFO_URL: &str = "https://oauth2.googleapis.com/tokeninfo"; +const DEFAULT_TIMEOUT_MS: u64 = 5_000; +const MIN_TIMEOUT_MS: u64 = 100; +const MAX_TIMEOUT_MS: u64 = 60_000; + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum UsernameClaim { + #[default] + Email, + UserId, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Settings { + pub(crate) tokeninfo_url: String, + pub(crate) timeout_ms: u64, + pub(crate) require_tls: bool, + pub(crate) username_claim: UsernameClaim, + pub(crate) strip_email_domain: bool, + pub(crate) require_user_match: bool, + pub(crate) require_verified_email: bool, + pub(crate) allowed_audiences: Vec, + pub(crate) allowed_domains: Vec, + pub(crate) allowed_emails: Vec, + pub(crate) required_scopes: Vec, + pub(crate) provision: bool, + pub(crate) impersonate: bool, + pub(crate) server_user: Option, + pub(crate) server_password_env: Option, + pub(crate) read_only: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + tokeninfo_url: DEFAULT_TOKENINFO_URL.into(), + timeout_ms: DEFAULT_TIMEOUT_MS, + require_tls: true, + username_claim: UsernameClaim::Email, + strip_email_domain: false, + require_user_match: true, + require_verified_email: true, + allowed_audiences: Vec::new(), + allowed_domains: Vec::new(), + allowed_emails: Vec::new(), + required_scopes: Vec::new(), + provision: false, + impersonate: true, + server_user: None, + server_password_env: None, + read_only: None, + } + } +} + +pub(crate) struct RuntimeConfig { + pub(crate) settings: Settings, + pub(crate) endpoint: Url, + pub(crate) client: Client, + pub(crate) server_password: Option, +} + +impl RuntimeConfig { + pub(crate) fn load(path: Option<&Path>) -> Result { + let settings = match path { + Some(path) => { + let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.display().to_string(), + source, + })?; + toml::from_str(&contents).map_err(|source| ConfigError::Parse { + path: path.display().to_string(), + source, + })? + } + None => Settings::default(), + }; + + Self::from_settings(settings) + } + + pub(crate) fn from_settings(mut settings: Settings) -> Result { + if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&settings.timeout_ms) { + return Err(ConfigError::Invalid(format!( + "timeout_ms must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}" + ))); + } + + normalize_list(&mut settings.allowed_audiences, "allowed_audiences", false)?; + // Without an audience allow-list, any Google access token for any + // OAuth client logs its owner in: a token the user granted to an + // unrelated application would authenticate them against this database + // (token confusion). The audience is what binds a token to this + // deployment, so there is no usable default. + if settings.allowed_audiences.is_empty() { + return Err(ConfigError::Invalid( + "allowed_audiences is required: list the OAuth client IDs (the \"aud\"/\"azp\" claims) whose access tokens may log in".into(), + )); + } + normalize_list(&mut settings.required_scopes, "required_scopes", false)?; + normalize_list(&mut settings.allowed_domains, "allowed_domains", true)?; + normalize_list(&mut settings.allowed_emails, "allowed_emails", true)?; + + let endpoint = validate_endpoint(&settings.tokeninfo_url)?; + let timeout = Duration::from_millis(settings.timeout_ms); + let client = Client::builder() + .connect_timeout(timeout) + .timeout(timeout) + .redirect(Policy::none()) + .user_agent(concat!("pgdog-google-auth/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(ConfigError::Client)?; + + let server_password = if settings.provision { + if settings.allowed_domains.is_empty() && settings.allowed_emails.is_empty() { + return Err(ConfigError::Invalid( + "provision = true requires allowed_domains or allowed_emails".into(), + )); + } + + let server_user = settings + .server_user + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ConfigError::Invalid("provision = true requires server_user".into()) + })?; + settings.server_user = Some(server_user.to_owned()); + + let variable = settings + .server_password_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ConfigError::Invalid("provision = true requires server_password_env".into()) + })? + .to_owned(); + settings.server_password_env = Some(variable.clone()); + + let password = env::var(&variable).map_err(|_| ConfigError::MissingSecret { + variable: variable.clone(), + })?; + if password.is_empty() { + return Err(ConfigError::MissingSecret { variable }); + } + Some(password) + } else { + None + }; + + Ok(Self { + settings, + endpoint, + client, + server_password, + }) + } +} + +impl Settings { + /// Whether the startup user belongs to the Google-authenticated namespace. + /// + /// A full email startup user can be routed before token introspection when + /// the token-derived email must match it. Other identity modes need the + /// token response before ownership can be determined, so the plugin claims + /// them and preserves fail-closed behavior. + pub(crate) fn claims_user(&self, user: &str) -> bool { + if !self.require_user_match + || self.username_claim != UsernameClaim::Email + || self.strip_email_domain + { + return true; + } + + let normalized = user.to_ascii_lowercase(); + let Some((local, domain)) = normalized.rsplit_once('@') else { + return false; + }; + if local.is_empty() || domain.is_empty() { + return false; + } + + if self.allowed_domains.is_empty() && self.allowed_emails.is_empty() { + return true; + } + + self.allowed_emails + .iter() + .any(|allowed| allowed == &normalized) + || self.allowed_domains.iter().any(|allowed| allowed == domain) + } +} + +fn normalize_list( + values: &mut [String], + field: &'static str, + lowercase: bool, +) -> Result<(), ConfigError> { + for value in values { + *value = value.trim().to_owned(); + if value.is_empty() { + return Err(ConfigError::Invalid(format!( + "{field} cannot contain empty values" + ))); + } + if lowercase { + value.make_ascii_lowercase(); + } + } + Ok(()) +} + +fn validate_endpoint(value: &str) -> Result { + let url = Url::parse(value).map_err(ConfigError::Url)?; + + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::Invalid( + "tokeninfo_url cannot contain credentials, a query, or a fragment".into(), + )); + } + + match url.scheme() { + "https" => Ok(url), + "http" if loopback(&url) => Ok(url), + _ => Err(ConfigError::Invalid( + "tokeninfo_url must use HTTPS; HTTP is allowed only for loopback tests".into(), + )), + } +} + +fn loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + +#[derive(Debug, Error)] +pub(crate) enum ConfigError { + #[error("failed to read Google auth config {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse Google auth config {path}: {source}")] + Parse { + path: String, + #[source] + source: toml::de::Error, + }, + #[error("invalid tokeninfo_url: {0}")] + Url(#[source] url::ParseError), + #[error("failed to create Google tokeninfo client: {0}")] + Client(#[source] reqwest::Error), + #[error("missing backend password in environment variable {variable}")] + MissingSecret { variable: String }, + #[error("invalid Google auth configuration: {0}")] + Invalid(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_secure() { + let settings = Settings::default(); + + assert!(settings.require_tls); + assert!(settings.require_user_match); + assert!(settings.require_verified_email); + assert!(settings.impersonate); + assert!(!settings.provision); + assert_eq!(settings.username_claim, UsernameClaim::Email); + } + + /// Settings that load: `allowed_audiences` is mandatory, so every test + /// that expects `from_settings` to succeed has to set it. + fn loadable() -> Settings { + Settings { + allowed_audiences: vec!["gcloud-client".into()], + ..Default::default() + } + } + + #[test] + fn requires_an_audience_allowlist() { + let error = RuntimeConfig::from_settings(Settings::default()) + .err() + .expect("an empty allowed_audiences must not load"); + assert!(error.to_string().contains("allowed_audiences is required")); + } + + #[test] + fn rejects_non_loopback_http_endpoint() { + let settings = Settings { + tokeninfo_url: "http://example.com/tokeninfo".into(), + ..Default::default() + }; + + assert!(RuntimeConfig::from_settings(settings).is_err()); + } + + #[test] + fn accepts_loopback_http_endpoint_for_tests() { + for tokeninfo_url in [ + "http://127.0.0.1:12345/tokeninfo", + "http://[::1]:12345/tokeninfo", + "http://localhost:12345/tokeninfo", + ] { + let settings = Settings { + tokeninfo_url: tokeninfo_url.into(), + ..loadable() + }; + + assert!( + RuntimeConfig::from_settings(settings).is_ok(), + "{tokeninfo_url}" + ); + } + } + + #[test] + fn rejects_auto_provisioning_without_principal_allowlist() { + let settings = Settings { + provision: true, + server_user: Some("pgdog_service".into()), + server_password_env: Some("PGDOG_TEST_PASSWORD".into()), + ..loadable() + }; + + let error = RuntimeConfig::from_settings(settings) + .err() + .expect("configuration should fail"); + assert!(error.to_string().contains("allowed_domains")); + } + + #[test] + fn parses_config_file() { + let directory = tempfile::tempdir().expect("create temp directory"); + let path = directory.path().join("google-auth.toml"); + fs::write( + &path, + r#" +require_tls = true +username_claim = "user_id" +allowed_audiences = ["gcloud-client"] +allowed_domains = ["Example.COM"] +required_scopes = ["scope-a"] +"#, + ) + .expect("write config"); + + let runtime = RuntimeConfig::load(Some(&path)).expect("load config"); + assert_eq!(runtime.settings.username_claim, UsernameClaim::UserId); + assert_eq!(runtime.settings.allowed_audiences, ["gcloud-client"]); + assert_eq!(runtime.settings.allowed_domains, ["example.com"]); + assert_eq!(runtime.settings.required_scopes, ["scope-a"]); + } + + #[test] + fn routes_only_matching_full_email_users_when_possible() { + let settings = Settings { + allowed_domains: vec!["example.com".into()], + allowed_emails: vec!["specific@other.test".into()], + ..Default::default() + }; + + assert!(settings.claims_user("alice@example.com")); + assert!(settings.claims_user("specific@other.test")); + assert!(!settings.claims_user("postgres")); + assert!(!settings.claims_user("alice@other.test")); + + let derived_identity = Settings { + require_user_match: false, + ..settings.clone() + }; + assert!(derived_identity.claims_user("postgres")); + + let stripped_email = Settings { + strip_email_domain: true, + ..settings.clone() + }; + assert!(stripped_email.claims_user("alice")); + + let user_id = Settings { + username_claim: UsernameClaim::UserId, + ..settings + }; + assert!(user_id.claims_user("1234567890")); + } +} diff --git a/plugins/pgdog-google-auth/src/lib.rs b/plugins/pgdog-google-auth/src/lib.rs new file mode 100644 index 000000000..676026e29 --- /dev/null +++ b/plugins/pgdog-google-auth/src/lib.rs @@ -0,0 +1,56 @@ +//! Google OAuth 2.0 access-token authentication for PgDog. + +mod config; +mod token_info; + +use std::{path::Path, sync::Arc}; + +use config::RuntimeConfig; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use pgdog_plugin::{AuthContext, AuthDecision, Config as PluginConfig, PdStr, Plugin, plugin}; +use tracing::{error, info}; + +plugin!(GoogleAuthPlugin); + +struct GoogleAuthPlugin; + +static RUNTIME: Lazy>>> = Lazy::new(|| RwLock::new(None)); + +impl Plugin for GoogleAuthPlugin { + extern "C-unwind" fn version() -> PdStr<'static> { + env!("CARGO_PKG_VERSION").into() + } + + extern "C-unwind" fn config(config: PluginConfig<'_>) -> bool { + let path = (!config.plugin_config.is_empty()).then(|| Path::new(&*config.plugin_config)); + + match RuntimeConfig::load(path) { + Ok(runtime) => { + info!("[pgdog_google_auth] configured Google access-token authentication"); + *RUNTIME.write() = Some(Arc::new(runtime)); + true + } + Err(err) => { + error!("[pgdog_google_auth] configuration failed: {err}"); + *RUNTIME.write() = None; + false + } + } + } + + fn authenticate(context: AuthContext<'_>) -> AuthDecision { + let Some(runtime) = RUNTIME.read().clone() else { + return AuthDecision::Deny("Google authentication plugin is not configured".into()); + }; + + if !runtime.settings.claims_user(&context.user) { + return AuthDecision::Skip; + } + + match token_info::authenticate(&runtime, &context.user, &context.credential, context.tls) { + Ok(grant) => AuthDecision::Allow(grant), + Err(err) => AuthDecision::Deny(err.to_string()), + } + } +} diff --git a/plugins/pgdog-google-auth/src/token_info.rs b/plugins/pgdog-google-auth/src/token_info.rs new file mode 100644 index 000000000..7430dfe4a --- /dev/null +++ b/plugins/pgdog-google-auth/src/token_info.rs @@ -0,0 +1,667 @@ +use std::{ + collections::HashSet, + io::{Read, Take}, +}; + +use pgdog_plugin::AuthGrant; +use serde::Deserialize; +use thiserror::Error; + +use crate::config::{RuntimeConfig, Settings, UsernameClaim}; + +const MAX_TOKEN_LENGTH: usize = 16 * 1024; +const MAX_RESPONSE_LENGTH: u64 = 64 * 1024; +const MAX_POSTGRES_USERNAME_LENGTH: usize = 63; + +#[derive(Debug, Deserialize)] +struct TokenInfo { + #[serde(alias = "aud")] + audience: Option, + #[serde(alias = "azp")] + issued_to: Option, + #[serde(alias = "sub")] + user_id: Option, + scope: Option, + #[serde(default, deserialize_with = "deserialize_str_i64")] + expires_in: Option, + email: Option, + #[serde( + default, + alias = "email_verified", + deserialize_with = "deserialize_str_bool" + )] + verified_email: Option, +} + +/// Google's tokeninfo endpoint stringifies booleans (`"email_verified": +/// "true"`), while other identity endpoints use real JSON booleans; accept +/// both. `deserialize_with` disables serde's implicit missing-field handling +/// for `Option`, hence the explicit `default` on the field above. +fn deserialize_str_bool<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + struct StrBool; + + impl serde::de::Visitor<'_> for StrBool { + type Value = bool; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(r#"a boolean or "true"/"false""#) + } + + fn visit_bool(self, value: bool) -> Result + where + E: serde::de::Error, + { + Ok(value) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(E::unknown_variant(value, &["true", "false"])), + } + } + } + + deserializer.deserialize_any(StrBool).map(Some) +} + +/// Seconds are a JSON number on some endpoints and a decimal string on +/// others (Google's tokeninfo sends `"expires_in": "3599"`); accept both, and +/// reject anything else rather than panicking on a bad parse. As with +/// [`deserialize_str_bool`], `deserialize_with` disables serde's implicit +/// handling of a missing `Option` field, hence the `default` on the field. +fn deserialize_str_i64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + struct StrI64; + + impl serde::de::Visitor<'_> for StrI64 { + type Value = i64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an integer or a decimal string") + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + Ok(value) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + i64::try_from(value).map_err(|_| E::custom("value is out of range")) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + value + .trim() + .parse() + .map_err(|_| E::custom("expected a decimal string")) + } + } + + deserializer.deserialize_any(StrI64).map(Some) +} + +pub(crate) fn authenticate( + runtime: &RuntimeConfig, + startup_user: &str, + credential: &str, + tls: bool, +) -> Result { + if runtime.settings.require_tls && !tls { + return Err(AuthenticationError::TlsRequired); + } + if credential.is_empty() || credential.len() > MAX_TOKEN_LENGTH { + return Err(AuthenticationError::InvalidCredential); + } + + let token_info = fetch(runtime, credential)?; + validate(&runtime.settings, startup_user, token_info).map(|username| AuthGrant { + derived_user: Some(username.clone()), + server_role: runtime.settings.impersonate.then_some(username), + server_user: runtime.settings.server_user.clone(), + server_password: runtime.server_password.clone(), + read_only: runtime.settings.read_only, + provision: runtime.settings.provision, + }) +} + +fn fetch(runtime: &RuntimeConfig, credential: &str) -> Result { + // POST the token in the request body. A query string ends up in access + // logs, proxy logs and browser-style history along the way; Google accepts + // both spellings. + let response = runtime + .client + .post(runtime.endpoint.clone()) + .form(&[("access_token", credential)]) + .send() + .map_err(request_error)?; + + if !response.status().is_success() { + return Err(AuthenticationError::Rejected); + } + + let mut body = Vec::new(); + let mut limited: Take<_> = response.take(MAX_RESPONSE_LENGTH + 1); + limited + .read_to_end(&mut body) + .map_err(|error| AuthenticationError::Request(error.to_string()))?; + if body.len() as u64 > MAX_RESPONSE_LENGTH { + return Err(AuthenticationError::ResponseTooLarge); + } + + serde_json::from_slice(&body).map_err(AuthenticationError::InvalidResponse) +} + +fn request_error(error: reqwest::Error) -> AuthenticationError { + AuthenticationError::Request(error.without_url().to_string()) +} + +fn validate( + settings: &Settings, + startup_user: &str, + token_info: TokenInfo, +) -> Result { + // A token that reports no remaining lifetime, or no lifetime at all, is + // not accepted. + if token_info.expires_in.unwrap_or_default() <= 0 { + return Err(AuthenticationError::Expired); + } + + validate_audience(settings, &token_info)?; + validate_scopes(settings, &token_info)?; + + let email = token_info + .email + .as_deref() + .map(str::trim) + .filter(|email| !email.is_empty()) + .map(str::to_ascii_lowercase); + let email_required = settings.username_claim == UsernameClaim::Email + || !settings.allowed_domains.is_empty() + || !settings.allowed_emails.is_empty(); + + if email_required && email.is_none() { + return Err(AuthenticationError::MissingEmail); + } + if email_required && settings.require_verified_email && token_info.verified_email != Some(true) + { + return Err(AuthenticationError::UnverifiedEmail); + } + if email_required { + validate_email(email.as_deref().ok_or(AuthenticationError::MissingEmail)?)?; + } + + if !settings.allowed_domains.is_empty() || !settings.allowed_emails.is_empty() { + let email = email.as_deref().ok_or(AuthenticationError::MissingEmail)?; + let (_, domain) = validate_email(email)?; + let allowed = settings + .allowed_emails + .iter() + .any(|allowed| allowed == email) + || settings + .allowed_domains + .iter() + .any(|allowed| allowed == domain); + if !allowed { + return Err(AuthenticationError::PrincipalNotAllowed); + } + } + + let username = match settings.username_claim { + UsernameClaim::Email => { + let email = email.ok_or(AuthenticationError::MissingEmail)?; + if settings.strip_email_domain { + validate_email(&email)?.0.to_owned() + } else { + email + } + } + UsernameClaim::UserId => token_info + .user_id + .as_deref() + .map(str::trim) + .filter(|user_id| !user_id.is_empty()) + .map(str::to_owned) + .ok_or(AuthenticationError::MissingUserId)?, + }; + + if username.len() > MAX_POSTGRES_USERNAME_LENGTH { + return Err(AuthenticationError::UsernameTooLong); + } + // The name becomes a PostgreSQL role, a `server_role` startup parameter + // and a PgDog config entry. Only reachable from a hostile `tokeninfo_url`, + // since Google does not mint identities like this, but cheap to refuse. + if username + .chars() + .any(|c| c.is_control() || c.is_whitespace()) + { + return Err(AuthenticationError::InvalidUsername); + } + // `claims_user` decides whether to handle this login by lower-casing the + // startup user, so compare the same way: otherwise `Alice@Example.com` is + // claimed and then denied instead of being left to the next plugin. + if settings.require_user_match && !startup_user.eq_ignore_ascii_case(&username) { + return Err(AuthenticationError::UserMismatch); + } + + Ok(username) +} + +fn validate_email(email: &str) -> Result<(&str, &str), AuthenticationError> { + let (local, domain) = email + .rsplit_once('@') + .ok_or(AuthenticationError::InvalidEmail)?; + + if local.is_empty() || domain.is_empty() { + return Err(AuthenticationError::InvalidEmail); + } + + Ok((local, domain)) +} + +fn validate_audience( + settings: &Settings, + token_info: &TokenInfo, +) -> Result<(), AuthenticationError> { + // `RuntimeConfig::from_settings` refuses to build without an audience + // allow-list; if an empty one ever reaches here, accept nothing. + let accepted = !settings.allowed_audiences.is_empty() + && token_info + .audience + .iter() + .chain(token_info.issued_to.iter()) + .any(|audience| { + settings + .allowed_audiences + .iter() + .any(|allowed| allowed == audience) + }); + + accepted + .then_some(()) + .ok_or(AuthenticationError::AudienceNotAllowed) +} + +fn validate_scopes(settings: &Settings, token_info: &TokenInfo) -> Result<(), AuthenticationError> { + if settings.required_scopes.is_empty() { + return Ok(()); + } + + let scopes: HashSet<_> = token_info + .scope + .as_deref() + .unwrap_or_default() + .split_whitespace() + .collect(); + + settings + .required_scopes + .iter() + .all(|scope| scopes.contains(scope.as_str())) + .then_some(()) + .ok_or(AuthenticationError::MissingScope) +} + +#[derive(Debug, Error)] +pub(crate) enum AuthenticationError { + #[error("Google access tokens require a TLS client connection")] + TlsRequired, + #[error("invalid Google access token")] + InvalidCredential, + #[error("Google tokeninfo request failed: {0}")] + Request(String), + #[error("Google rejected the access token")] + Rejected, + #[error("Google tokeninfo response exceeded the size limit")] + ResponseTooLarge, + #[error("Google tokeninfo returned an invalid response: {0}")] + InvalidResponse(#[source] serde_json::Error), + #[error("Google access token is expired")] + Expired, + #[error("Google access token has no email identity")] + MissingEmail, + #[error("Google access token email is not verified")] + UnverifiedEmail, + #[error("Google access token contains an invalid email identity")] + InvalidEmail, + #[error("Google account is not allowed")] + PrincipalNotAllowed, + #[error("Google access token audience is not allowed")] + AudienceNotAllowed, + #[error("Google access token is missing a required scope")] + MissingScope, + #[error("Google access token has no user_id identity")] + MissingUserId, + #[error("Google identity exceeds PostgreSQL's 63-byte user-name limit")] + UsernameTooLong, + #[error("Google identity contains characters PostgreSQL cannot use in a user name")] + InvalidUsername, + #[error("PostgreSQL startup user does not match the Google identity")] + UserMismatch, +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + use super::*; + use crate::config::Settings; + + fn token_info() -> TokenInfo { + TokenInfo { + audience: Some("gcloud-client".into()), + issued_to: None, + user_id: Some("1234567890".into()), + scope: Some("scope-a scope-b".into()), + expires_in: Some(3600), + email: Some("alice@example.com".into()), + verified_email: Some(true), + } + } + + fn settings() -> Settings { + Settings { + require_tls: false, + allowed_audiences: vec!["gcloud-client".into()], + allowed_domains: vec!["example.com".into()], + required_scopes: vec!["scope-a".into()], + ..Default::default() + } + } + + fn mock_server( + status: &'static str, + body: &'static str, + ) -> (String, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + let address = listener.local_addr().expect("mock server address"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0u8; 4096]; + let size = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..size]).into_owned(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + request + }); + + (format!("http://{address}/tokeninfo"), handle) + } + + #[test] + fn accepts_verified_allowed_identity() { + let username = validate(&settings(), "alice@example.com", token_info()) + .expect("token should validate"); + + assert_eq!(username, "alice@example.com"); + } + + #[test] + fn can_use_user_id_as_postgres_identity() { + let settings = Settings { + username_claim: UsernameClaim::UserId, + require_user_match: false, + require_verified_email: false, + allowed_audiences: vec!["gcloud-client".into()], + ..Default::default() + }; + + assert_eq!( + validate(&settings, "ignored", token_info()).expect("token should validate"), + "1234567890" + ); + } + + #[test] + fn rejects_expired_token() { + let mut token = token_info(); + token.expires_in = Some(0); + + assert!(matches!( + validate(&settings(), "alice@example.com", token), + Err(AuthenticationError::Expired) + )); + } + + #[test] + fn rejects_unverified_email() { + let mut token = token_info(); + token.verified_email = Some(false); + + assert!(matches!( + validate(&settings(), "alice@example.com", token), + Err(AuthenticationError::UnverifiedEmail) + )); + } + + #[test] + fn rejects_disallowed_audience_domain_and_scope() { + let mut audience = settings(); + audience.allowed_audiences = vec!["different-client".into()]; + assert!(matches!( + validate(&audience, "alice@example.com", token_info()), + Err(AuthenticationError::AudienceNotAllowed) + )); + + let mut domain = settings(); + domain.allowed_domains = vec!["other.example".into()]; + assert!(matches!( + validate(&domain, "alice@example.com", token_info()), + Err(AuthenticationError::PrincipalNotAllowed) + )); + + let mut scope = settings(); + scope.required_scopes = vec!["scope-c".into()]; + assert!(matches!( + validate(&scope, "alice@example.com", token_info()), + Err(AuthenticationError::MissingScope) + )); + } + + #[test] + fn rejects_startup_user_mismatch() { + assert!(matches!( + validate(&settings(), "bob@example.com", token_info()), + Err(AuthenticationError::UserMismatch) + )); + } + + #[test] + fn rejects_malformed_email_identity() { + for email in ["alice", "@example.com", "alice@"] { + let mut token = token_info(); + token.email = Some(email.into()); + + assert!(matches!( + validate(&settings(), email, token), + Err(AuthenticationError::InvalidEmail) + )); + } + } + + #[test] + fn accepts_oidc_tokeninfo_field_names() { + let token_info: TokenInfo = serde_json::from_str( + r#"{ + "aud": "gcloud-client", + "azp": "authorized-party", + "sub": "1234567890", + "scope": "scope-a", + "expires_in": "3600", + "email": "alice@example.com", + "email_verified": "true" + }"#, + ) + .expect("parse tokeninfo response"); + + assert_eq!( + validate(&settings(), "alice@example.com", token_info).expect("token should validate"), + "alice@example.com" + ); + } + + #[test] + fn accepts_boolean_and_missing_email_verified() { + // Other Google identity endpoints send a real JSON boolean. + let token_info: TokenInfo = serde_json::from_str( + r#"{"expires_in": "3600", "email": "alice@example.com", "email_verified": true}"#, + ) + .expect("parse boolean email_verified"); + assert_eq!(token_info.verified_email, Some(true)); + + // Tokens without the email scope omit the field entirely. + let token_info: TokenInfo = + serde_json::from_str(r#"{"expires_in": "3600"}"#).expect("parse missing field"); + assert_eq!(token_info.verified_email, None); + + assert!( + serde_json::from_str::(r#"{"expires_in": "3600", "email_verified": "yes"}"#) + .is_err() + ); + } + + #[test] + fn accepts_numeric_and_string_expires_in() { + // Google's tokeninfo stringifies it; other endpoints send a number. + let token_info: TokenInfo = + serde_json::from_str(r#"{"expires_in": 3599, "email": "alice@example.com"}"#) + .expect("parse numeric expires_in"); + assert_eq!(token_info.expires_in, Some(3599)); + + let token_info: TokenInfo = + serde_json::from_str(r#"{"expires_in": "3599", "email": "alice@example.com"}"#) + .expect("parse string expires_in"); + assert_eq!(token_info.expires_in, Some(3599)); + + // A value that is neither is a response error, not a panic. + assert!( + serde_json::from_str::(r#"{"expires_in": "soon"}"#).is_err(), + "a non-numeric expires_in must be rejected" + ); + + // Missing entirely: treated as no lifetime left. + let token_info: TokenInfo = + serde_json::from_str(r#"{"email": "alice@example.com"}"#).expect("parse without it"); + assert_eq!(token_info.expires_in, None); + assert!(matches!( + validate(&settings(), "alice@example.com", token_info), + Err(AuthenticationError::Expired) + )); + } + + #[test] + fn matches_the_startup_user_case_insensitively() { + // `Settings::claims_user` lower-cases before deciding whether to + // handle the login, so validation must not then reject the same user. + assert!(settings().claims_user("Alice@Example.com")); + assert_eq!( + validate(&settings(), "Alice@Example.com", token_info()).expect("should validate"), + "alice@example.com" + ); + } + + #[test] + fn rejects_identities_postgres_cannot_use() { + for email in ["ali ce@example.com", "alice\u{7}@example.com"] { + let mut token = token_info(); + token.email = Some(email.into()); + + let mut settings = settings(); + settings.require_user_match = false; + settings.allowed_domains = Vec::new(); + settings.allowed_emails = Vec::new(); + + assert!( + matches!( + validate(&settings, "ignored", token), + Err(AuthenticationError::InvalidUsername) + ), + "{email}" + ); + } + } + + #[test] + fn calls_tokeninfo_without_leaking_token_in_errors() { + let body = r#"{ + "azp": "42789329387.apps.googleusercontent.com", + "aud": "42789329387.apps.googleusercontent.com", + "sub": "427893293874278932938", + "scope": "email https://www.googleapis.com/auth/accounts.reauth https://www.googleapis.com/auth/appengine.admin https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/compute https://www.googleapis.com/auth/sqlservice.login https://www.googleapis.com/auth/userinfo.email openid", + "exp": "1787664074", + "expires_in": "2865", + "email": "marco.palmisano@examplecompany.com", + "email_verified": "true", + "access_type": "offline" + }"#; + let (url, request) = mock_server("200 OK", body); + let runtime = RuntimeConfig::from_settings(Settings { + tokeninfo_url: url, + require_tls: false, + require_user_match: false, + allowed_audiences: vec!["42789329387.apps.googleusercontent.com".into()], + ..Default::default() + }) + .expect("create runtime"); + let token = "ya29.a+b/c?"; + + let grant = authenticate(&runtime, "ignored", token, false).expect("authenticate"); + assert_eq!( + grant.derived_user.as_deref(), + Some("marco.palmisano@examplecompany.com") + ); + + let request = request.join().expect("join mock server"); + assert!(request.starts_with("POST /tokeninfo")); + // Nothing of the token in the request line, only in the form body. + let (head, body) = request.split_once("\r\n\r\n").expect("request has a body"); + assert!(!head.contains("access_token")); + assert!(body.starts_with("access_token=")); + assert!(!request.contains(token)); + } + + #[test] + fn rejects_non_success_response() { + let (url, request) = mock_server("400 Bad Request", r#"{"error":"invalid_token"}"#); + let runtime = RuntimeConfig::from_settings(Settings { + tokeninfo_url: url, + require_tls: false, + require_user_match: false, + allowed_audiences: vec!["42789329387.apps.googleusercontent.com".into()], + ..Default::default() + }) + .expect("create runtime"); + + assert!(matches!( + authenticate(&runtime, "ignored", "bad-token", false), + Err(AuthenticationError::Rejected) + )); + request.join().expect("join mock server"); + } +} From 19ee4715b6eafc32b9dfedf90e310f6b4941fec9 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 12:21:23 +0200 Subject: [PATCH 12/13] feat: build and test the Google auth plugin in Docker and CI Build libpgdog_google_auth.so in the Docker image alongside the primary-only-tables plugin and copy it to /usr/lib. The plugin builds no longer pass the pgdog-only `cargo_features` array, which the plugin crates do not define and which would fail the build when FEATURES is set. Add the plugin's unit tests to the plugin-ci workflow and tighten the workflow: drop continue-on-error so failures block, pin the checkout action to a commit, drop the rust-cache step, add a manual dispatch trigger, and restrict the token to contents: read. The main-ent branch and the *.rs path filters added on main are kept. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/plugin-ci.yml | 13 +++++++------ Dockerfile | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 83fb615e6..333d85f56 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -1,4 +1,5 @@ name: plugin-ci + on: push: branches: @@ -10,18 +11,18 @@ on: types: [opened, synchronize, reopened] paths: - "**/*.rs" + workflow_dispatch: + +permissions: + contents: read jobs: plugin-unit-tests: runs-on: blacksmith-4vcpu-ubuntu-2404 - continue-on-error: true timeout-minutes: 30 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Install CI deps run: bash integration/ci/install-deps.sh - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: "plugin-unit-v1" - name: Run plugin unit tests - run: cargo nextest run -E 'package(pgdog-example-plugin)' --no-fail-fast + run: cargo nextest run -E 'package(pgdog-example-plugin) | package(pgdog-google-auth)' --no-fail-fast diff --git a/Dockerfile b/Dockerfile index 9d16bf856..7082b25f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,13 +17,15 @@ RUN source ~/.cargo/env && \ cd pgdog && \ cargo build --release "${cargo_features[@]}" && \ cd .. && \ - cargo build --release -p pgdog-primary-only-tables "${cargo_features[@]}" + cargo build --release -p pgdog-primary-only-tables && \ + cargo build --release -p pgdog-google-auth FROM ${RUNTIME_BASE} ENV RUST_LOG=info COPY --from=builder /build/target/release/pgdog /usr/local/bin/pgdog COPY --from=builder /build/target/release/libpgdog_primary_only_tables.so /usr/lib/libpgdog_primary_only_tables.so +COPY --from=builder /build/target/release/libpgdog_google_auth.so /usr/lib/libpgdog_google_auth.so WORKDIR /pgdog STOPSIGNAL SIGINT From 8fcfc455b1c7406f50cabe0d2674b848c61683e0 Mon Sep 17 00:00:00 2001 From: Marco Palmisano Date: Tue, 15 Sep 2026 13:45:03 +0200 Subject: [PATCH 13/13] test: add Google access-token plugin integration suite Add integration/plugins/google, an rspec suite that exercises the pgdog-google-auth plugin end to end through PgDog with auth_type = "plugin". The spec starts its own tokeninfo mock on 127.0.0.1:18080 and google-auth.toml points the plugin at it over loopback HTTP, so no Google credentials or network access are needed. The mock answers a fixed set of tokens: a valid one for alice@example.com, an expired one, one for a different email, one missing the required scope, and valid ones for dave@example.com and carol@example.com. The suite checks that a valid token logs in and runs a query, that the plugin's grant sets server_role so current_user is alice@example.com on a pre-configured pool, that a non-email user such as pgdog is skipped and then authenticated by PostgreSQL passthrough, that unknown, expired, mismatched, and under-scoped tokens are rejected with the generic auth error, that a valid identity with no pool is rejected while provisioning is off, and that a login whose impersonated role does not exist in Postgres fails instead of running as the service account. setup.sql creates the alice@example.com role and grants it to the pgdog service account, and deliberately leaves dave@example.com without a role. run.sh builds the plugin into the workspace target and runs the suite as a third phase after the generic auth plugin suite. Co-Authored-By: Claude Fable 5.1 --- integration/plugins/google/google-auth.toml | 6 + .../plugins/google/google_auth_spec.rb | 229 ++++++++++++++++++ integration/plugins/google/pgdog.toml | 12 + integration/plugins/google/setup.sql | 16 ++ integration/plugins/google/users.toml | 19 ++ integration/plugins/run.sh | 19 ++ 6 files changed, 301 insertions(+) create mode 100644 integration/plugins/google/google-auth.toml create mode 100644 integration/plugins/google/google_auth_spec.rb create mode 100644 integration/plugins/google/pgdog.toml create mode 100644 integration/plugins/google/setup.sql create mode 100644 integration/plugins/google/users.toml diff --git a/integration/plugins/google/google-auth.toml b/integration/plugins/google/google-auth.toml new file mode 100644 index 000000000..d49821a54 --- /dev/null +++ b/integration/plugins/google/google-auth.toml @@ -0,0 +1,6 @@ +tokeninfo_url = "http://127.0.0.1:18080/tokeninfo" +require_tls = false +require_user_match = true +allowed_emails = ["alice@example.com", "dave@example.com", "carol@example.com"] +allowed_audiences = ["gcloud-client"] +required_scopes = ["https://www.googleapis.com/auth/cloud-platform"] diff --git a/integration/plugins/google/google_auth_spec.rb b/integration/plugins/google/google_auth_spec.rb new file mode 100644 index 000000000..788a1820c --- /dev/null +++ b/integration/plugins/google/google_auth_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'pg' +require 'rspec' +require 'json' +require 'socket' +require 'uri' + +GENERIC_AUTH_ERROR = /is wrong, or the database does not exist/ + +TOKEN_RESPONSES = { + # `expires_in` as a JSON number: Google's tokeninfo stringifies it, other + # endpoints do not, and both have to parse. This is the token whose login + # must succeed, so a regression here fails the suite. + 'valid-google-token' => { + audience: 'gcloud-client', + user_id: '1234567890', + scope: 'openid email https://www.googleapis.com/auth/cloud-platform', + expires_in: 3600, + email: 'alice@example.com', + verified_email: 'true' + }, + 'expired-google-token' => { + audience: 'gcloud-client', + user_id: '1234567890', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '0', + email: 'alice@example.com', + verified_email: 'true' + }, + 'wrong-email-token' => { + audience: 'gcloud-client', + user_id: '9876543210', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '3600', + email: 'bob@example.com', + verified_email: 'true' + }, + 'missing-scope-token' => { + audience: 'gcloud-client', + user_id: '1234567890', + scope: 'openid email', + expires_in: '3600', + email: 'alice@example.com', + verified_email: 'true' + }, + 'dave-google-token' => { + audience: 'gcloud-client', + user_id: '2222222222', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '3600', + email: 'dave@example.com', + verified_email: 'true' + }, + 'carol-google-token' => { + audience: 'gcloud-client', + user_id: '3333333333', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '3600', + email: 'carol@example.com', + verified_email: 'true' + } +}.freeze + +class TokenInfoServer + def initialize + @server = TCPServer.new('127.0.0.1', 18_080) + @mutex = Mutex.new + @request_lines = [] + @thread = Thread.new { serve } + end + + def stop + @server.close + @thread.join + end + + # Request lines seen so far, so a spec can assert how the token was sent. + def request_lines + @mutex.synchronize { @request_lines.dup } + end + + private + + def serve + loop do + socket = @server.accept + request_line = socket.gets + @mutex.synchronize { @request_lines << request_line.to_s.strip } + headers = read_headers(socket) + + token = request_line && access_token(request_line, headers, socket) + body = TOKEN_RESPONSES[token] + if body + respond(socket, '200 OK', JSON.generate(body)) + else + respond(socket, '400 Bad Request', JSON.generate(error: 'invalid_token')) + end + rescue IOError, Errno::EBADF + break + ensure + socket&.close + end + end + + def read_headers(socket) + headers = {} + while (line = socket.gets) + break if line == "\r\n" + + name, value = line.split(':', 2) + headers[name.strip.downcase] = value.strip if value + end + headers + end + + # The plugin POSTs the token as a form body, so it never reaches a URL or an + # access log. The query-string spelling Google also accepts is still read + # here, so this mock does not silently pin the plugin to one of them. + def access_token(request_line, headers, socket) + method, target, = request_line.split + return URI.decode_www_form(URI(target).query.to_s).to_h['access_token'] unless method == 'POST' + + length = headers['content-length'].to_i + body = length.positive? ? socket.read(length) : '' + URI.decode_www_form(body).to_h['access_token'] + end + + def respond(socket, status, body) + socket.write( + "HTTP/1.1 #{status}\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Length: #{body.bytesize}\r\n" \ + "Connection: close\r\n\r\n" \ + "#{body}" + ) + end +end + +def connect(user, token) + PG.connect( + host: '127.0.0.1', + port: 6432, + user: user, + password: token, + dbname: 'pgdog' + ) +end + +describe 'Google access-token authentication plugin' do + before(:all) do + @token_info = TokenInfoServer.new + end + + after(:all) do + @token_info.stop + end + + it 'accepts a valid Google access token and runs a query' do + conn = connect('alice@example.com', 'valid-google-token') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'never puts the access token in the request URL' do + conn = connect('alice@example.com', 'valid-google-token') + conn.close + + lines = @token_info.request_lines + expect(lines).not_to be_empty + expect(lines).to all(start_with('POST /tokeninfo')) + expect(lines.join("\n")).not_to include('access_token') + end + + it 'impersonates the Google identity on a pre-configured pool' do + # alice's users.toml entry has no `server_role`; the plugin's grant fills + # it, so queries run as the authenticated identity, not the service account. + conn = connect('alice@example.com', 'valid-google-token') + expect(conn.exec('SELECT current_user AS u')[0]['u']).to eq('alice@example.com') + conn.close + end + + it 'skips excluded users so PostgreSQL passthrough can authenticate them' do + conn = connect('pgdog', 'pgdog') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'rejects a token Google does not recognize with a generic error' do + expect { connect('alice@example.com', 'invalid-google-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects an expired token' do + expect { connect('alice@example.com', 'expired-google-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects a token for a different Google identity' do + expect { connect('alice@example.com', 'wrong-email-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects a token missing a required scope' do + expect { connect('alice@example.com', 'missing-scope-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects a valid login for an identity with no pool when provisioning is off' do + expect { connect('carol@example.com', 'carol-google-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + # Keep this last: it leaves dave's pool unable to connect to Postgres. + it 'fails the connection when the impersonated role does not exist in Postgres' do + # dave authenticates and has a configured pool, but setup.sql never created + # his Postgres role: the backend refuses the `role` startup parameter, so + # the login (or, with cached server parameters, the first query) fails + # rather than falling back to the service account. + expect do + conn = connect('dave@example.com', 'dave-google-token') + begin + conn.exec('SELECT current_user') + ensure + conn.close + end + end.to raise_error(PG::Error) + end +end diff --git a/integration/plugins/google/pgdog.toml b/integration/plugins/google/pgdog.toml new file mode 100644 index 000000000..8997a9946 --- /dev/null +++ b/integration/plugins/google/pgdog.toml @@ -0,0 +1,12 @@ +[general] +auth_type = "plugin" +passthrough_auth = "enabled_plain" +log_connections = true + +[[plugins]] +name = "pgdog_google_auth" +config = "integration/plugins/google/google-auth.toml" + +[[databases]] +name = "pgdog" +host = "127.0.0.1" diff --git a/integration/plugins/google/setup.sql b/integration/plugins/google/setup.sql new file mode 100644 index 000000000..ccaa1f1ed --- /dev/null +++ b/integration/plugins/google/setup.sql @@ -0,0 +1,16 @@ +-- Postgres-side prerequisites for the Google access-token suite. +-- +-- Run directly against PostgreSQL (not through PgDog) as the `pgdog` service +-- account. `run.sh` applies this before starting PgDog. + +-- Role impersonated after a Google login as alice@example.com. It needs no +-- LOGIN: PgDog connects as the `pgdog` service account and assumes the role +-- through the `role` startup parameter. +DROP ROLE IF EXISTS "alice@example.com"; +CREATE ROLE "alice@example.com" NOLOGIN; +GRANT "alice@example.com" TO pgdog; + +-- dave@example.com deliberately has NO role here: his pool is configured, so +-- his Google login succeeds, but the backend connection must fail instead of +-- silently running queries as the service account. +DROP ROLE IF EXISTS "dave@example.com"; diff --git a/integration/plugins/google/users.toml b/integration/plugins/google/users.toml new file mode 100644 index 000000000..82454dc55 --- /dev/null +++ b/integration/plugins/google/users.toml @@ -0,0 +1,19 @@ +# Pre-configured pool without `server_role`: the plugin's grant fills it in on +# login, so queries run as "alice@example.com", not as the service account. +# `setup.sql` creates the role and grants it to `pgdog`. +[[users]] +name = "alice@example.com" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" + +# Same shape, but `setup.sql` deliberately does NOT create the Postgres role, +# so the backend connection (and therefore the login) must fail. +[[users]] +name = "dave@example.com" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" + +# carol@example.com has no [[users]] entry and provisioning is off: her valid +# Google login must fail because there is no pool for her identity. diff --git a/integration/plugins/run.sh b/integration/plugins/run.sh index dc078adb7..714d78175 100644 --- a/integration/plugins/run.sh +++ b/integration/plugins/run.sh @@ -35,6 +35,11 @@ pushd ${SCRIPT_DIR}/../../plugins/pgdog-example-plugin build_plugin popd +# The Google plugin has no cargo features; build it into the workspace target. +pushd ${SCRIPT_DIR}/../../plugins/pgdog-google-auth +cargo build --release +popd + export LD_LIBRARY_PATH=${SCRIPT_DIR}/target/release:${SCRIPT_DIR}/../../target/release export DYLD_LIBRARY_PATH=${LD_LIBRARY_PATH} @@ -57,3 +62,17 @@ pushd ${SCRIPT_DIR} bundle exec rspec auth/auth_spec.rb popd stop_pgdog + +# Phase 3: Google access-token plugin (pgdog_google_auth). The spec starts a +# tokeninfo mock on 127.0.0.1:18080 itself; google/google-auth.toml points the +# plugin at it over loopback HTTP. setup.sql creates the impersonated role for +# alice@example.com and deliberately leaves dave@example.com without one. +PGPASSWORD=pgdog psql -h 127.0.0.1 -p 5432 -U pgdog -d pgdog -v ON_ERROR_STOP=1 \ + -f ${SCRIPT_DIR}/google/setup.sql + +run_pgdog ${SCRIPT_DIR}/google +wait_for_pgdog +pushd ${SCRIPT_DIR} +bundle exec rspec google/google_auth_spec.rb +popd +stop_pgdog