authentication plugins as a generic client-auth hook#1203
Open
ziomarco wants to merge 5 commits into
Open
Conversation
ziomarco
marked this pull request as draft
July 14, 2026 17:35
ziomarco
force-pushed
the
feat/auth-plugins
branch
from
July 14, 2026 18:21
a74e8d4 to
adb6c7c
Compare
ziomarco
marked this pull request as ready for review
July 14, 2026 18:23
ziomarco
force-pushed
the
feat/auth-plugins
branch
from
July 16, 2026 19:24
adb6c7c to
1983092
Compare
Add the AuthType::Plugin variant (auth_type = "plugin"), general.background_workers to bound concurrent auth-plugin calls, and users.server_role for backend role impersonation.
Extend the plugin vtable with an authenticate entry and a corresponding Plugin::authenticate trait method (default: Skip). The plugin returns an owned AuthDecision; the generated bridge streams each grant/deny string to a host callback as a borrowed PdStr and returns POD by value, so no ownership crosses the FFI boundary. Bumps pgdog-plugin to 0.5.0.
Drive the cleartext exchange for auth_type = "plugin", consult the
plugins via a spawn_blocking driver capped by background_workers, and on
Allow thread the derived user through the backend path and provision its
pool with databases::add_authenticated (no password comparison).
Add server_role impersonation: the role is sent as a startup parameter
(a session default that survives RESET ALL / DISCARD ALL), and the query
parser rejects client SET ROLE / RESET ROLE / SET SESSION AUTHORIZATION /
set_config('role', ...) on such pools with a 42501 error. Gate pool
launch on per-cluster backend credentials so impersonation and
plugin-auth pools without a stored client password still start.
Add test-plugin-auth (built on the Plugin trait) and an rspec suite exercising allow, deny, all-skip denial, a panicking plugin, derived-user provisioning, server_role impersonation, and the SET ROLE guard. Capture PgDog stderr into the integration log so the suite can assert on the plugin deny reason.
ziomarco
force-pushed
the
feat/auth-plugins
branch
from
July 20, 2026 09:09
1983092 to
44c7358
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is the first of two PRs reworking #1084 (JWT client auth) into a general extension point. It adds one new plugin hook,
pgdog_authenticate, next to the existingpgdog_route, so a plugin can validate a client credential. There is no JWT anywhere in this PR; core only learns how to ask a plugin "is this credential good?". It also adds a per-userserver_roleoption so a pool can impersonate a role on the backend, and abackground_workerssetting that caps how many auth plugin calls run at once.Design was discussed first in an RFC (gist: https://gist.github.com/ziomarco/ad18e5c5f65472f13671958514b30c0d) and approved with a few adjustments, all of which are implemented here.
Motivation
In #1084 I put JWT validation directly into
Client::check_password. The feedback was that it belongs in a plugin, but the plugin system only had a query-routing hook, so there was nowhere for auth to plug in. This PR builds that missing seam.Authentication is a multi-message exchange, so I did not try to hand the client socket to a plugin. pgdog keeps driving the wire exactly like passthrough auth does today: it sends
AuthenticationCleartextPassword, reads the password message, and only then asks the plugins about the credential. Each plugin answers Skip, Allow, or Deny, and the first non-Skip answer wins, in[[plugins]]order. The same hook works for LDAP, OIDC introspection, RADIUS, Vault, or any custom check without touching core again.The plugin call runs inside
tokio::task::spawn_blocking, since auth happens once per connection and a plugin may need to block on I/O (a JWKS fetch, an LDAP bind). That is different fromroute(), which runs per query and has to stay fast.What's in this PR
pgdog-plugin: thePdAuthContext/PdAuthResultFFI types, safeAuthContext/AuthDecision/AuthGrantwrappers, and the symbol resolution and call path. The result carries plugin-allocated strings, so there is a pairedpgdog_authenticate_free: pgdog copies the strings out and the plugin frees its own allocations, which stays sound even if a plugin uses a custom allocator. A plugin that exportspgdog_authenticatewithout the free symbol is refused at load.pgdog-macros: an#[authentication]attribute, the same idea as#[route]. It generates both symbols and wraps the user function incatch_unwind, so a panicking plugin becomes a Deny rather than taking down the pooler.pgdog-config: theauth_type = "plugin"variant, the per-userserver_role, andbackground_workers.pgdog: thespawn_blockingdriver, one new branch inClient::login,databases::add_authenticatedfor auto-provisioning a pool on Allow (the existingadd()compares the credential against the stored password, which cannot work with a per-login token), therolestartup parameter, the guard that rejects client-issued role changes on impersonation pools, and load-time validation.For
server_role, the role is pushed as arolestartup-packet parameter. Startup parameters are session defaults, soRESET ALLandDISCARD ALLrestore the role instead of clearing it, and pgdog's connection cleanup between checkouts needs no special handling. The one thing that does need handling is a client trying to escape the role, so pgdog rejectsSET ROLE,RESET ROLE, andSET SESSION AUTHORIZATION(including the multi-statement andset_config()spellings) on pools that set aserver_role. The statement is refused with a normal Postgres error and the session stays open.Decisions from the RFC review
A few points were settled during review and are worth calling out:
auth_type = "plugin"is explicit, so if nothing claims the credential the login fails.background_workers(default 4) caps concurrent auth plugin calls, so a slow or stuck plugin cannot exhaust the blocking pool.server_roleis a core per-user option, not something JWT-specific. A[[users]]entry that sets it has to supply a working backend credential (server_password, or aserver_authmechanism), since the user config no longer carries the Postgres password. pgdog warns at config load if that is missing.auth_type = "plain"and passthrough. pgdog warns at startup whentls_client_required = false.pgdog-plugingoes from 0.3.0 to 0.4.0 so stale plugins are rejected cleanly by the existing version gate. The in-tree plugins are updated to match.Testing
cargo nextest run --profile devis green (2260 tests). New unit tests cover the FFI round-trip (String toPdStringand back, the Allow/Deny/Skip encoding, and free-once-then-safe semantics), the auth driver's ordering and all-skip-denies behavior,add_authenticated's gap-filling and never-overwrite rules, and the role-escape guard across single-statement, multi-statement, andset_config()forms.There is a new integration suite under
integration/plugins/auth(run bybash integration/plugins/run.sh) with a smalltest-plugin-authbuilt through#[authentication]. It checks that a good credential connects and queries, that wrong / deny / panic / all-skip credentials all get the generic auth error while the deny reason stays in the pgdog log, that an impersonated role shows up inSELECT current_userand survives connection reuse and cleanup, that role-escape attempts are rejected, and that a read-only grant blocks INSERT. The existing routing-plugin spec runs unchanged as a regression check. I also walked through each auth scenario by hand with psql against a running pgdog.What's next
PR 2 is the JWT plugin itself: an in-tree
plugins/pgdog-jwt-authcdylib (the validator ported from #1084, with the JWKS cache made synchronous), built into the Docker image, plus theintegration/jwtsuite ported over and documentation for both the hook and the plugin. All of the JWT configuration moves out of[general]and into the plugin's own config file, which is what made thejwt_user_suffixhack from #1084 unnecessary.