Skip to content

plugins: Google access-token authentication plugin - #1576

Draft
ziomarco wants to merge 13 commits into
pgdogdev:mainfrom
ziomarco:split/google-auth-plugin
Draft

ziomarco wants to merge 13 commits into
pgdogdev:mainfrom
ziomarco:split/google-auth-plugin

Conversation

@ziomarco

Copy link
Copy Markdown
Contributor

Stacked on the plugin auth PRs; only the last three commits are new here.

This adds pgdog-google-auth, a plugin that lets Postgres clients log into PgDog with a Google OAuth 2.0 access token instead of a password, so PGPASSWORD="$(gcloud auth print-access-token)" psql ... just works. It is the first real consumer of the authenticate hook.

The plugin POSTs the token to Google's tokeninfo endpoint over HTTPS with redirects disabled and a bounded timeout — a form body rather than a query string, so the token does not end up in access logs on the way — then checks expiry, the OAuth audience, that the email is verified, and optional email, domain and scope allowlists before accepting.

allowed_audiences is required and the plugin refuses to start without it. Google will introspect any valid access token and report its owner's verified email, so without an audience check a token a user granted to some unrelated application authenticates that user against your database. The aud/azp claim is what ties a token to your own OAuth client, and there is no safe default; the README shows how to find the aud of a gcloud auth print-access-token token. The verified identity becomes the Postgres user and is returned as server_role, so with a shared service account pool the session still runs as the person's own role. The plugin can also provision that pool, with the backend password read from an environment variable so it never sits in the config file. Startup users that cannot be Google emails get a Skip so the rest of PgDog's auth chain applies, while claimed users with a bad token are denied and cannot fall back to a password.

Configuration is a small TOML file referenced from a [[plugins]] entry. The crate ships an example file and a README. There is no caching: each login costs one call to Google, and revocation applies to new logins only. expires_in parses from either a JSON number or a decimal string, since Google's tokeninfo stringifies it and other identity endpoints do not. The startup user is compared to the derived identity case-insensitively, matching how the plugin decides whether to claim a login at all, and an identity carrying whitespace or control characters is refused before it can become a role name.

tokeninfo_url = "https://oauth2.googleapis.com/tokeninfo"
allowed_domains = ["example.com"]
allowed_audiences = ["<your OAuth client id>"]
required_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
impersonate = true

The Docker image builds the plugin and plugin-ci runs its unit tests. There is also an rspec suite under integration/plugins/google that runs the plugin end to end through PgDog: the spec starts its own tokeninfo mock on loopback, so no Google credentials or network access are needed. It checks that a valid token logs in and that current_user is the Google identity on a pre-configured pool, that the token never appears in a request URL, that a non-email user is skipped and then authenticated by passthrough, that unknown, expired, mismatched and under-scoped tokens get 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.

🤖 Generated with Claude Code

ziomarco and others added 13 commits September 16, 2026 18:26
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>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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