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>
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 pgdog-plugin ABI PR; only the last three commits are new here.
This adds a per-user
server_rolesetting to users.toml so a pool can log in to Postgres as its configured server user and run every session as another role. It is usable on its own without any plugin, and the plugin host wiring builds on it in the next PR.The role is passed in the startup packet rather than set with
SET ROLEafter connecting, which makes it the session's reset value. I checked this against Postgres 18:RESET ROLEandDISCARD ALLfall back to the startup role, andRESET ALLleavesrolealone, so connection cleanup does not need to special-case it.Clients on such a pool cannot change the role themselves.
SET ROLE,RESET ROLE,SET SESSION AUTHORIZATIONand theset_config('role', ...)spellings, including inside multi-statement queries and over the extended protocol, get a normal 42501 error and the session stays open. To make that reliable the query parser is always used for these users, since the regex fast path would otherwise letSELECT set_config(...)through. Aroleparameter that a client sends in its own startup packet is dropped on these pools with a warning, because PgDog would otherwise sync it to the server asSET "role"on every checkout.PgDog warns at config load when a
server_roleuser has no backend credential of its own, and the users.toml JSON schema and example file document the setting.One thing to be clear about: the parser guard is a best effort filter, not the security boundary. It is a denylist over the AST and can be worked around, for example with
set_config('ro'||'le', ...), aset_configcall inside a CTE or a second select target, or a plpgsqlDOblock. The real boundary is Postgres membership:SET ROLEonly works for roles the session user is a member of, so the server user should only be granted the roles it is meant to impersonate.What the guard does have to get right is that an escape cannot outlive the session that made it.
roleisGUC_NO_RESET_ALL, soRESET ALLleaves 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 forserver_rolepools, which restores the startup-packet value, i.e. the impersonated role, not "no role".DISCARD ALLalready covered this, so the extra statement only appears on the cleanup paths that did not.backend::pool::test::test_server_role_restored_on_checkinescapes the role behind the parser's back (talking straight to the server, as aDOblock would) and asserts the next checkout of the same connection is back to the impersonated role; without the cleanup change it inherits the escaped one.Forcing the query parser on for these users silently overrides
query_parser = "off", so config load now warns about that per user, and the field documentation says so too.🤖 Generated with Claude Code