Conversation
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 <noreply@anthropic.com>
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<Library> 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 <noreply@anthropic.com>
A [[users]] entry can set server_role. The pool keeps connecting as server_user and passes role=<server_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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
A cdylib test plugin (integration/plugins/test-plugins/test-plugin-auth) decides from the credential the client sends: "deny", "panic", "secret-<user>", "impersonate:<role>" 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 <noreply@anthropic.com>
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 pgdogdev#1483 and pgdogdev#1539. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.
Stacked on the
auth_type = "plugin"PR; only the last two commits are new here.This changes what happens under
auth_type = "plugin"when every loaded plugin returns Skip. During the RFC review we agreed that all-skip means deny, since the setting is explicit, and the previous PR implements exactly that. I am proposing to reverse that decision here and want to be upfront about it.The reason is that
auth_typeis a single global setting. The deployment I run mixes humans, who authenticate with Google tokens through 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 we run a second PgDog just for them.With this change, when no plugin makes a decision, PgDog verifies the cleartext credential the client already sent against the user's configured password: plain entries with a constant-time compare, and SCRAM-SHA-256 verifiers (
password_hash) through a newscram::verify_password, because a second MD5 or SCRAM exchange cannot be started on the same connection. A configured user is verified against its own configured password whether or not passthrough is enabled — deferring to passthrough there would go throughdatabases::add, which compares only thepasswordfield, so a user carrying just apassword_hashand aserver_passwordwould have had the first credential that arrived accepted and stored. Passthrough applies to users that are not in the configuration, exactly as it does without plugins. An explicit Deny and a plugin failure (panic, task failure or an unusable grant) stay terminal, so a plugin can still refuse a user it recognises regardless of any configured password.A user that is configured but has no client password at all (no
password,passwords,password_hashorvault_path) is treated as plugin-only and is denied rather than falling back or being bootstrapped by passthrough. Be aware that giving a plugin user apasswordas its backend credential now makes that password a valid client login, so plugin-only users should useserver_password; theAuthType::Plugindocs andexample.pgdog.tomlsay this too. Admin logins are unaffected.The check runs through
maybe_spawn_blockingso the PBKDF2 derivation stays off the runtime whenbackground_workersis enabled, in line with #1483 and #1539. The passthrough path callsdatabases::addlike today's passthrough branch; once the verify-before-persist PR lands this call site should switch toadd_passthrough.If you would rather not have a global fallback, I am happy to do a per-user
auth_typeor aplugin_skip_fallbacktoggle defaulting to today's deny instead. Either fits inplugin_fallback.🤖 Generated with Claude Code