Skip to content

authentication plugins as a generic client-auth hook#1203

Open
ziomarco wants to merge 5 commits into
pgdogdev:mainfrom
ziomarco:feat/auth-plugins
Open

authentication plugins as a generic client-auth hook#1203
ziomarco wants to merge 5 commits into
pgdogdev:mainfrom
ziomarco:feat/auth-plugins

Conversation

@ziomarco

Copy link
Copy Markdown

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 existing pgdog_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-user server_role option so a pool can impersonate a role on the backend, and a background_workers setting 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 from route(), which runs per query and has to stay fast.

What's in this PR

  • pgdog-plugin: the PdAuthContext / PdAuthResult FFI types, safe AuthContext / AuthDecision / AuthGrant wrappers, and the symbol resolution and call path. The result carries plugin-allocated strings, so there is a paired pgdog_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 exports pgdog_authenticate without 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 in catch_unwind, so a panicking plugin becomes a Deny rather than taking down the pooler.
  • pgdog-config: the auth_type = "plugin" variant, the per-user server_role, and background_workers.
  • pgdog: the spawn_blocking driver, one new branch in Client::login, databases::add_authenticated for auto-provisioning a pool on Allow (the existing add() compares the credential against the stored password, which cannot work with a per-login token), the role startup parameter, the guard that rejects client-issued role changes on impersonation pools, and load-time validation.

For server_role, the role is pushed as a role startup-packet parameter. Startup parameters are session defaults, so RESET ALL and DISCARD ALL restore 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 rejects SET ROLE, RESET ROLE, and SET SESSION AUTHORIZATION (including the multi-statement and set_config() spellings) on pools that set a server_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:

  • When every plugin skips, pgdog denies. There is no fallback to configured-password verification: 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_role is 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 a server_auth mechanism), since the user config no longer carries the Postgres password. pgdog warns at config load if that is missing.
  • The credential travels in-band as a cleartext password, same exposure as auth_type = "plain" and passthrough. pgdog warns at startup when tls_client_required = false.
  • pgdog-plugin goes 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 dev is green (2260 tests). New unit tests cover the FFI round-trip (String to PdString and 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, and set_config() forms.

There is a new integration suite under integration/plugins/auth (run by bash integration/plugins/run.sh) with a small test-plugin-auth built 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 in SELECT current_user and 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-auth cdylib (the validator ported from #1084, with the JWKS cache made synchronous), built into the Docker image, plus the integration/jwt suite 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 the jwt_user_suffix hack from #1084 unnecessary.

@ziomarco
ziomarco marked this pull request as draft July 14, 2026 17:35
@ziomarco
ziomarco force-pushed the feat/auth-plugins branch from a74e8d4 to adb6c7c Compare July 14, 2026 18:21
@ziomarco
ziomarco marked this pull request as ready for review July 14, 2026 18:23
@ziomarco
ziomarco force-pushed the feat/auth-plugins branch from adb6c7c to 1983092 Compare July 16, 2026 19:24
ziomarco added 5 commits July 20, 2026 10:20
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
ziomarco force-pushed the feat/auth-plugins branch from 1983092 to 44c7358 Compare July 20, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant