diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 83fb615e6..333d85f56 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -1,4 +1,5 @@ name: plugin-ci + on: push: branches: @@ -10,18 +11,18 @@ on: types: [opened, synchronize, reopened] paths: - "**/*.rs" + workflow_dispatch: + +permissions: + contents: read jobs: plugin-unit-tests: runs-on: blacksmith-4vcpu-ubuntu-2404 - continue-on-error: true timeout-minutes: 30 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Install CI deps run: bash integration/ci/install-deps.sh - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: "plugin-unit-v1" - name: Run plugin unit tests - run: cargo nextest run -E 'package(pgdog-example-plugin)' --no-fail-fast + run: cargo nextest run -E 'package(pgdog-example-plugin) | package(pgdog-google-auth)' --no-fail-fast diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index a8609bdc1..9b4b811ad 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -323,6 +323,11 @@ "description": "Plaintext password.", "type": "string", "const": "plain" + }, + { + "description": "Delegate client authentication to the loaded plugins through the\nauthenticate hook. The client sends its credential in plaintext and the\nfirst plugin that does not skip decides; when every plugin skips, the\nlogin is denied (there is no password fallback).", + "type": "string", + "const": "plugin" } ] }, diff --git a/.schema/users.schema.json b/.schema/users.schema.json index 2333addd4..3a78933bf 100644 --- a/.schema/users.schema.json +++ b/.schema/users.schema.json @@ -329,6 +329,13 @@ "null" ] }, + "server_role": { + "description": "PostgreSQL role this user's backend connections assume. PgDog connects\nas `server_user` and passes `role` in the startup packet, which makes\nit the session's reset value: `RESET ROLE` and `DISCARD ALL` fall back\nto it and `RESET ALL` leaves it untouched, so connection cleanup never\nclears it. Clients on this pool cannot change it: `SET ROLE`,\n`RESET ROLE`, `SET SESSION AUTHORIZATION` and the `set_config(...)`\nspellings are rejected with a permission error.\n\n**Note:** `server_user` needs a working backend credential of its own\n(`server_password` or a non-password `server_auth`) and must be a\nmember of `server_role`.", + "type": [ + "string", + "null" + ] + }, "server_user": { "description": "Which user to connect with when creating backend connections from PgDog to PostgreSQL. By default, the user configured in `name` is used. This setting allows you to override this configuration and use a different user.\n\n**Note:** Values specified in `pgdog.toml` take priority over this configuration.\n\n", "type": [ diff --git a/Cargo.lock b/Cargo.lock index ed2671ce0..ab4b5433c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3213,6 +3213,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "pgdog-google-auth" +version = "0.1.0" +dependencies = [ + "once_cell", + "parking_lot", + "pgdog-plugin", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "tracing", + "url", +] + [[package]] name = "pgdog-jsonschema" version = "0.1.0" @@ -3237,7 +3254,7 @@ dependencies = [ [[package]] name = "pgdog-plugin" -version = "0.4.0" +version = "0.5.0" dependencies = [ "bindgen 0.71.1", "libloading", @@ -3764,6 +3781,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -3784,6 +3802,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index 41fff8e2a..0ed67210f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,9 @@ members = [ "pgdog-postgres-types", "pgdog-stats", "pgdog-vector", - "plugins/pgdog-example-plugin", "plugins/pgdog-primary-only-tables", + "plugins/pgdog-example-plugin", + "plugins/pgdog-google-auth", + "plugins/pgdog-primary-only-tables", "scripts/*", ] @@ -19,7 +21,7 @@ members = [ edition = "2024" [workspace.dependencies] -pgdog-plugin = { path = "./pgdog-plugin", version = "0.4.0", default-features = false } +pgdog-plugin = { path = "./pgdog-plugin", version = "0.5.0", default-features = false } pgdog-config = { path = "./pgdog-config", version = "0.1.0" } pgdog-postgres-types = { path = "./pgdog-postgres-types"} pg_raw_parse = { git = "https://github.com/pgdogdev/pg_raw_parse.git", rev = "6a6e16719b48eae2c5897e2e28de8d2bd65184f9" } diff --git a/Dockerfile b/Dockerfile index 9d16bf856..7082b25f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,13 +17,15 @@ RUN source ~/.cargo/env && \ cd pgdog && \ cargo build --release "${cargo_features[@]}" && \ cd .. && \ - cargo build --release -p pgdog-primary-only-tables "${cargo_features[@]}" + cargo build --release -p pgdog-primary-only-tables && \ + cargo build --release -p pgdog-google-auth FROM ${RUNTIME_BASE} ENV RUST_LOG=info COPY --from=builder /build/target/release/pgdog /usr/local/bin/pgdog COPY --from=builder /build/target/release/libpgdog_primary_only_tables.so /usr/lib/libpgdog_primary_only_tables.so +COPY --from=builder /build/target/release/libpgdog_google_auth.so /usr/lib/libpgdog_google_auth.so WORKDIR /pgdog STOPSIGNAL SIGINT diff --git a/example.pgdog.toml b/example.pgdog.toml index c1d52021e..126752bed 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -291,6 +291,20 @@ mirror_queue = 128 # - scram # - md5 # - trust +# - plain +# - plugin: delegate client authentication to the loaded plugins that implement +# the authenticate hook (see the [[plugins]] section). PgDog asks the client +# for a plaintext credential (so use TLS) and consults the plugins in +# [[plugins]] order; the first one that does not skip decides. When every +# plugin skips, PgDog checks the credential against the user's configured +# password, or uses passthrough authentication for users that are not in +# users.toml; a configured user without a client password is plugin-only and +# is denied. An explicit deny is final. A configured password stays usable +# for login, so omit it for users that must only authenticate through a +# plugin. Plugin calls run on the Tokio blocking pool, whose size is +# bounded by `background_workers`; with the default of 0 that pool has a +# single thread and plugin calls run one at a time. +# auth_type = "plugin" auth_type = "scram" # Disable cross-shard queries. # diff --git a/example.users.toml b/example.users.toml index 564711555..b732850f2 100644 --- a/example.users.toml +++ b/example.users.toml @@ -48,3 +48,21 @@ password = "pgdog" # a client certificate, while mTLS users share the same listener. Defaults to true. # Only applies when `tls_client_ca_certificate` is set in pgdog.toml. # tls_client_certificate_required = false + +# Example: role impersonation. Backend connections for this user log in as +# `server_user` and assume `server_role` through the `role` startup parameter, +# which makes it the session's reset value: `RESET ROLE` / `DISCARD ALL` fall +# back to it and `RESET ALL` leaves it untouched. +# `SET ROLE`, `RESET ROLE` and `SET SESSION AUTHORIZATION` (including the +# `set_config(...)` spellings) are rejected on this pool with a permission error. +# +# `server_user` must be a member of `server_role` and needs its own backend +# credential (`server_password` or a non-password `server_auth`); PgDog warns +# at config load if none is configured. +# [[users]] +# name = "analytics" +# database = "pgdog" +# password = "analytics" +# server_role = "analytics_ro" +# server_user = "pgdog" +# server_password = "pgdog" diff --git a/integration/common.sh b/integration/common.sh index 87f79c4e0..662c62fe4 100644 --- a/integration/common.sh +++ b/integration/common.sh @@ -63,10 +63,12 @@ function run_pgdog() { --users ${config_path}/users.toml \ > ${COMMON_DIR}/log.txt 2>&1 & else + # Capture stdout AND stderr: PgDog's tracing logs go to stderr, and the + # plugins auth suite asserts on log lines via integration/log.txt. "${binary}" \ --config ${config_path}/pgdog.toml \ --users ${config_path}/users.toml \ - > ${COMMON_DIR}/log.txt & + > ${COMMON_DIR}/log.txt 2>&1 & fi echo $! > "${pid_file}" printf '%s\n' "${config_path}" > "${config_file}" diff --git a/integration/plugins/auth/auth_spec.rb b/integration/plugins/auth/auth_spec.rb new file mode 100644 index 000000000..3dd606292 --- /dev/null +++ b/integration/plugins/auth/auth_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'pg' +require 'rspec' + +# PgDog's stdout/stderr is redirected here by integration/common.sh's run_pgdog. +# The authentication driver warn!-logs deny reasons, so the suite can assert +# they land in the log but never reach the client. +LOG_FILE = File.expand_path('../../../log.txt', __FILE__) + +GENERIC_AUTH_ERROR = /is wrong, or the database does not exist/ + +def connect(user, password, dbname: 'pgdog') + # Hash form avoids URL-encoding the ':' in credentials like + # "impersonate:reporting". + PG.connect(host: '127.0.0.1', port: 6432, user: user, password: password, dbname: dbname) +end + +def current_user(conn) + conn.exec('SELECT current_user AS u')[0]['u'] +end + +# Poll the PgDog log for a line matching `pattern`. Rust's stdout is +# line-buffered even when redirected, so a short poll is enough. +def wait_for_log(pattern, timeout: 5.0) + deadline = Time.now + timeout + loop do + return true if File.exist?(LOG_FILE) && File.read(LOG_FILE).match?(pattern) + return false if Time.now > deadline + + sleep 0.1 + end +end + +describe 'authentication plugin' do + it 'allows a client whose credential matches secret- and can query' do + conn = connect('alice', 'secret-alice') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + + # The connection line names the mechanism that actually authenticated the + # client, not passthrough. + expect(wait_for_log(/client "alice" connected.*auth: plugin/)).to be(true) + end + + it 'falls back to the configured password when every plugin skips' do + conn = connect('alice', 'postgres-alice') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'rejects a wrong credential with a generic auth error' do + expect { connect('alice', 'secret-bob') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'denies "deny" generically, logging the reason only in PgDog' do + error = nil + begin + connect('alice', 'deny') + raise 'expected the connection to be denied' + rescue PG::ConnectionBad => e + error = e + end + + # Client sees the generic error, never the plugin's reason. + expect(error.message).to match(GENERIC_AUTH_ERROR) + expect(error.message).not_to include('test deny') + + # PgDog logs the actual reason for the operator. + expect(wait_for_log(/denied by plugin .*test deny/)).to be(true) + end + + it 'survives a panicking plugin and keeps serving' do + expect { connect('alice', 'panic') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + + # PgDog is still alive: a subsequent good login works. + conn = connect('alice', 'secret-alice') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'rejects an unknown credential when plugin and password authentication fail' do + expect { connect('alice', 'no-such-credential') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'provisions a pool and impersonates the derived role' do + conn = connect('reporting', 'impersonate:reporting') + expect(current_user(conn)).to eq('reporting') + conn.close + end + + it 'impersonates the derived role on a pre-configured pool' do + # `auditor` is configured in users.toml without `server_role`; the grant + # must fill it in so queries do not run as the `pgdog` service account. + conn = connect('auditor', 'impersonate:auditor') + expect(current_user(conn)).to eq('auditor') + conn.close + end + + it 'keeps the impersonated role across connection reuse and cleanup' do + conn = connect('reporting', 'impersonate:reporting') + + # Several transactions force the backend connection through the pool's + # cleanup path (RESET ALL / DISCARD ALL) between checkouts. Dirtying the + # session with a SET must not clear the role, which is a startup-parameter + # session default rather than a runtime SET. + 10.times do + conn.exec('BEGIN') + conn.exec("SET work_mem = '8MB'") + expect(current_user(conn)).to eq('reporting') + conn.exec('COMMIT') + end + + expect(current_user(conn)).to eq('reporting') + conn.close + end + + it 'rejects role escapes on the impersonation pool but keeps the session usable' do + conn = connect('reporting', 'impersonate:reporting') + + escapes = [ + 'SET ROLE someone_else', + 'RESET ROLE', + 'SET SESSION AUTHORIZATION someone_else', + # Multi-statement batch: the role change must be rejected even when it + # rides along with an innocuous statement. + 'SELECT 1; SET ROLE someone_else', + # set_config with a non-constant value would otherwise pass through + # verbatim, escaping the guard. + "SELECT set_config('role', 'some' || 'one_else', false)" + ] + escapes.each do |stmt| + expect { conn.exec(stmt) }.to raise_error(PG::Error, /impersonates a fixed role/) + # Session survives the rejection and the role is unchanged. + expect(current_user(conn)).to eq('reporting') + end + + # A multi-statement batch with no role change is still accepted. + conn.exec('SELECT 1; SELECT 2') + expect(current_user(conn)).to eq('reporting') + + conn.close + end + + it 'denies a grant whose derived user PgDog cannot use' do + # "impersonate:" with nothing after it makes the plugin derive an empty + # user name, which would otherwise provision a pool (and a users.toml + # entry) called "". + expect { connect('alice', 'impersonate:') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + + expect(wait_for_log(/unusable grant: derived_user is empty/)).to be(true) + + # The rejected grant left nothing behind: the real login still works. + conn = connect('alice', 'secret-alice') + expect(current_user(conn)).to eq('pgdog') + conn.close + end + + it 'rejects INSERT on a read-only pool' do + conn = connect('readonly', 'secret-readonly') + expect { conn.exec('INSERT INTO auth_test (id) VALUES (1)') } + .to raise_error(PG::Error, /read-only transaction/) + conn.close + end +end diff --git a/integration/plugins/auth/pgdog.toml b/integration/plugins/auth/pgdog.toml new file mode 100644 index 000000000..63734a6e9 --- /dev/null +++ b/integration/plugins/auth/pgdog.toml @@ -0,0 +1,19 @@ +# +# Authentication-plugin integration suite. +# +# `auth_type = "plugin"` tries the loaded authentication plugins before +# configured password authentication. `test_plugin_auth` (see +# test-plugins/test-plugin-auth) makes the decisions the specs assert on. +# +[general] +auth_type = "plugin" +# Deny reasons and the connection lines the specs assert on are logged at +# warn!/info! only while this is on. +log_connections = true + +[[plugins]] +name = "test_plugin_auth" + +[[databases]] +name = "pgdog" +host = "127.0.0.1" diff --git a/integration/plugins/auth/setup.sql b/integration/plugins/auth/setup.sql new file mode 100644 index 000000000..9a5dea25e --- /dev/null +++ b/integration/plugins/auth/setup.sql @@ -0,0 +1,22 @@ +-- Postgres-side prerequisites for the authentication-plugin suite. +-- +-- Run directly against PostgreSQL (not through PgDog) as the `pgdog` service +-- account. `run.sh` applies this before starting PgDog. + +-- Role impersonated via `impersonate:reporting`. It needs no LOGIN: PgDog +-- connects as the `pgdog` service account and assumes the role through the +-- `role` startup parameter. Grant it to the service account so a non-superuser +-- deployment could assume it too. +DROP ROLE IF EXISTS reporting; +CREATE ROLE reporting NOLOGIN; +GRANT reporting TO pgdog; + +-- Role impersonated via `impersonate:auditor`. Unlike `reporting`, its pool is +-- pre-configured in users.toml (without `server_role`), so the plugin's grant +-- has to fill the gap rather than provision the pool. +DROP ROLE IF EXISTS auditor; +CREATE ROLE auditor NOLOGIN; +GRANT auditor TO pgdog; + +-- Target table for the read-only pool INSERT rejection test. +CREATE TABLE IF NOT EXISTS auth_test (id BIGINT); diff --git a/integration/plugins/auth/users.toml b/integration/plugins/auth/users.toml new file mode 100644 index 000000000..938ced113 --- /dev/null +++ b/integration/plugins/auth/users.toml @@ -0,0 +1,34 @@ +# +# Users for the authentication-plugin suite. +# +# Plugin-authenticated clients use `server_user`/`server_password` for the +# backend pool. The `alice` entry also has a client password to exercise the +# fallback used when every plugin skips. +# +# Impersonation users (e.g. `impersonate:reporting`) are not listed here: the +# plugin derives them and PgDog auto-provisions their pools. + +# Plain allow via `secret-alice`, no derivation, read-write. +[[users]] +name = "alice" +database = "pgdog" +password = "postgres-alice" +server_user = "pgdog" +server_password = "pgdog" + +# Read-only pool: `INSERT` must fail with a read-only error. +[[users]] +name = "readonly" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" +read_only = true + +# Pre-configured pool with no `server_role`: the plugin's grant must fill the +# gap so `impersonate:auditor` still runs queries as `auditor`, not as the +# `pgdog` service account. +[[users]] +name = "auditor" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" diff --git a/integration/plugins/google/google-auth.toml b/integration/plugins/google/google-auth.toml new file mode 100644 index 000000000..d49821a54 --- /dev/null +++ b/integration/plugins/google/google-auth.toml @@ -0,0 +1,6 @@ +tokeninfo_url = "http://127.0.0.1:18080/tokeninfo" +require_tls = false +require_user_match = true +allowed_emails = ["alice@example.com", "dave@example.com", "carol@example.com"] +allowed_audiences = ["gcloud-client"] +required_scopes = ["https://www.googleapis.com/auth/cloud-platform"] diff --git a/integration/plugins/google/google_auth_spec.rb b/integration/plugins/google/google_auth_spec.rb new file mode 100644 index 000000000..788a1820c --- /dev/null +++ b/integration/plugins/google/google_auth_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'pg' +require 'rspec' +require 'json' +require 'socket' +require 'uri' + +GENERIC_AUTH_ERROR = /is wrong, or the database does not exist/ + +TOKEN_RESPONSES = { + # `expires_in` as a JSON number: Google's tokeninfo stringifies it, other + # endpoints do not, and both have to parse. This is the token whose login + # must succeed, so a regression here fails the suite. + 'valid-google-token' => { + audience: 'gcloud-client', + user_id: '1234567890', + scope: 'openid email https://www.googleapis.com/auth/cloud-platform', + expires_in: 3600, + email: 'alice@example.com', + verified_email: 'true' + }, + 'expired-google-token' => { + audience: 'gcloud-client', + user_id: '1234567890', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '0', + email: 'alice@example.com', + verified_email: 'true' + }, + 'wrong-email-token' => { + audience: 'gcloud-client', + user_id: '9876543210', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '3600', + email: 'bob@example.com', + verified_email: 'true' + }, + 'missing-scope-token' => { + audience: 'gcloud-client', + user_id: '1234567890', + scope: 'openid email', + expires_in: '3600', + email: 'alice@example.com', + verified_email: 'true' + }, + 'dave-google-token' => { + audience: 'gcloud-client', + user_id: '2222222222', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '3600', + email: 'dave@example.com', + verified_email: 'true' + }, + 'carol-google-token' => { + audience: 'gcloud-client', + user_id: '3333333333', + scope: 'https://www.googleapis.com/auth/cloud-platform', + expires_in: '3600', + email: 'carol@example.com', + verified_email: 'true' + } +}.freeze + +class TokenInfoServer + def initialize + @server = TCPServer.new('127.0.0.1', 18_080) + @mutex = Mutex.new + @request_lines = [] + @thread = Thread.new { serve } + end + + def stop + @server.close + @thread.join + end + + # Request lines seen so far, so a spec can assert how the token was sent. + def request_lines + @mutex.synchronize { @request_lines.dup } + end + + private + + def serve + loop do + socket = @server.accept + request_line = socket.gets + @mutex.synchronize { @request_lines << request_line.to_s.strip } + headers = read_headers(socket) + + token = request_line && access_token(request_line, headers, socket) + body = TOKEN_RESPONSES[token] + if body + respond(socket, '200 OK', JSON.generate(body)) + else + respond(socket, '400 Bad Request', JSON.generate(error: 'invalid_token')) + end + rescue IOError, Errno::EBADF + break + ensure + socket&.close + end + end + + def read_headers(socket) + headers = {} + while (line = socket.gets) + break if line == "\r\n" + + name, value = line.split(':', 2) + headers[name.strip.downcase] = value.strip if value + end + headers + end + + # The plugin POSTs the token as a form body, so it never reaches a URL or an + # access log. The query-string spelling Google also accepts is still read + # here, so this mock does not silently pin the plugin to one of them. + def access_token(request_line, headers, socket) + method, target, = request_line.split + return URI.decode_www_form(URI(target).query.to_s).to_h['access_token'] unless method == 'POST' + + length = headers['content-length'].to_i + body = length.positive? ? socket.read(length) : '' + URI.decode_www_form(body).to_h['access_token'] + end + + def respond(socket, status, body) + socket.write( + "HTTP/1.1 #{status}\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Length: #{body.bytesize}\r\n" \ + "Connection: close\r\n\r\n" \ + "#{body}" + ) + end +end + +def connect(user, token) + PG.connect( + host: '127.0.0.1', + port: 6432, + user: user, + password: token, + dbname: 'pgdog' + ) +end + +describe 'Google access-token authentication plugin' do + before(:all) do + @token_info = TokenInfoServer.new + end + + after(:all) do + @token_info.stop + end + + it 'accepts a valid Google access token and runs a query' do + conn = connect('alice@example.com', 'valid-google-token') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'never puts the access token in the request URL' do + conn = connect('alice@example.com', 'valid-google-token') + conn.close + + lines = @token_info.request_lines + expect(lines).not_to be_empty + expect(lines).to all(start_with('POST /tokeninfo')) + expect(lines.join("\n")).not_to include('access_token') + end + + it 'impersonates the Google identity on a pre-configured pool' do + # alice's users.toml entry has no `server_role`; the plugin's grant fills + # it, so queries run as the authenticated identity, not the service account. + conn = connect('alice@example.com', 'valid-google-token') + expect(conn.exec('SELECT current_user AS u')[0]['u']).to eq('alice@example.com') + conn.close + end + + it 'skips excluded users so PostgreSQL passthrough can authenticate them' do + conn = connect('pgdog', 'pgdog') + expect(conn.exec('SELECT 1 AS n')[0]['n'].to_i).to eq(1) + conn.close + end + + it 'rejects a token Google does not recognize with a generic error' do + expect { connect('alice@example.com', 'invalid-google-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects an expired token' do + expect { connect('alice@example.com', 'expired-google-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects a token for a different Google identity' do + expect { connect('alice@example.com', 'wrong-email-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects a token missing a required scope' do + expect { connect('alice@example.com', 'missing-scope-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + it 'rejects a valid login for an identity with no pool when provisioning is off' do + expect { connect('carol@example.com', 'carol-google-token') } + .to raise_error(PG::ConnectionBad, GENERIC_AUTH_ERROR) + end + + # Keep this last: it leaves dave's pool unable to connect to Postgres. + it 'fails the connection when the impersonated role does not exist in Postgres' do + # dave authenticates and has a configured pool, but setup.sql never created + # his Postgres role: the backend refuses the `role` startup parameter, so + # the login (or, with cached server parameters, the first query) fails + # rather than falling back to the service account. + expect do + conn = connect('dave@example.com', 'dave-google-token') + begin + conn.exec('SELECT current_user') + ensure + conn.close + end + end.to raise_error(PG::Error) + end +end diff --git a/integration/plugins/google/pgdog.toml b/integration/plugins/google/pgdog.toml new file mode 100644 index 000000000..8997a9946 --- /dev/null +++ b/integration/plugins/google/pgdog.toml @@ -0,0 +1,12 @@ +[general] +auth_type = "plugin" +passthrough_auth = "enabled_plain" +log_connections = true + +[[plugins]] +name = "pgdog_google_auth" +config = "integration/plugins/google/google-auth.toml" + +[[databases]] +name = "pgdog" +host = "127.0.0.1" diff --git a/integration/plugins/google/setup.sql b/integration/plugins/google/setup.sql new file mode 100644 index 000000000..ccaa1f1ed --- /dev/null +++ b/integration/plugins/google/setup.sql @@ -0,0 +1,16 @@ +-- Postgres-side prerequisites for the Google access-token suite. +-- +-- Run directly against PostgreSQL (not through PgDog) as the `pgdog` service +-- account. `run.sh` applies this before starting PgDog. + +-- Role impersonated after a Google login as alice@example.com. It needs no +-- LOGIN: PgDog connects as the `pgdog` service account and assumes the role +-- through the `role` startup parameter. +DROP ROLE IF EXISTS "alice@example.com"; +CREATE ROLE "alice@example.com" NOLOGIN; +GRANT "alice@example.com" TO pgdog; + +-- dave@example.com deliberately has NO role here: his pool is configured, so +-- his Google login succeeds, but the backend connection must fail instead of +-- silently running queries as the service account. +DROP ROLE IF EXISTS "dave@example.com"; diff --git a/integration/plugins/google/users.toml b/integration/plugins/google/users.toml new file mode 100644 index 000000000..82454dc55 --- /dev/null +++ b/integration/plugins/google/users.toml @@ -0,0 +1,19 @@ +# Pre-configured pool without `server_role`: the plugin's grant fills it in on +# login, so queries run as "alice@example.com", not as the service account. +# `setup.sql` creates the role and grants it to `pgdog`. +[[users]] +name = "alice@example.com" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" + +# Same shape, but `setup.sql` deliberately does NOT create the Postgres role, +# so the backend connection (and therefore the login) must fail. +[[users]] +name = "dave@example.com" +database = "pgdog" +server_user = "pgdog" +server_password = "pgdog" + +# carol@example.com has no [[users]] entry and provisioning is off: her valid +# Google login must fail because there is no pool for her identity. diff --git a/integration/plugins/run.sh b/integration/plugins/run.sh index 5d60df708..714d78175 100644 --- a/integration/plugins/run.sh +++ b/integration/plugins/run.sh @@ -21,6 +21,10 @@ pushd ${SCRIPT_DIR}/test-plugins/test-plugin-compatible build_plugin popd +pushd ${SCRIPT_DIR}/test-plugins/test-plugin-auth +build_plugin +popd + pushd ${SCRIPT_DIR}/test-plugins/test-plugin-outdated cargo build --release popd @@ -31,6 +35,11 @@ pushd ${SCRIPT_DIR}/../../plugins/pgdog-example-plugin build_plugin popd +# The Google plugin has no cargo features; build it into the workspace target. +pushd ${SCRIPT_DIR}/../../plugins/pgdog-google-auth +cargo build --release +popd + export LD_LIBRARY_PATH=${SCRIPT_DIR}/target/release:${SCRIPT_DIR}/../../target/release export DYLD_LIBRARY_PATH=${LD_LIBRARY_PATH} @@ -40,3 +49,30 @@ wait_for_pgdog bash ${SCRIPT_DIR}/dev.sh stop_pgdog + +# Phase 2: authentication plugin (auth_type = "plugin"). +# Postgres-side prerequisites (impersonated roles, target table) go in first, +# applied directly against PostgreSQL rather than through PgDog. +PGPASSWORD=pgdog psql -h 127.0.0.1 -p 5432 -U pgdog -d pgdog -v ON_ERROR_STOP=1 \ + -f ${SCRIPT_DIR}/auth/setup.sql + +run_pgdog ${SCRIPT_DIR}/auth +wait_for_pgdog +pushd ${SCRIPT_DIR} +bundle exec rspec auth/auth_spec.rb +popd +stop_pgdog + +# Phase 3: Google access-token plugin (pgdog_google_auth). The spec starts a +# tokeninfo mock on 127.0.0.1:18080 itself; google/google-auth.toml points the +# plugin at it over loopback HTTP. setup.sql creates the impersonated role for +# alice@example.com and deliberately leaves dave@example.com without one. +PGPASSWORD=pgdog psql -h 127.0.0.1 -p 5432 -U pgdog -d pgdog -v ON_ERROR_STOP=1 \ + -f ${SCRIPT_DIR}/google/setup.sql + +run_pgdog ${SCRIPT_DIR}/google +wait_for_pgdog +pushd ${SCRIPT_DIR} +bundle exec rspec google/google_auth_spec.rb +popd +stop_pgdog diff --git a/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml b/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml new file mode 100644 index 000000000..9dca6333e --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-auth/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test-plugin-auth" +version = "0.1.0" +edition = "2024" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pgdog-plugin = { path = "../../../../pgdog-plugin" } diff --git a/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs new file mode 100644 index 000000000..9a89fceed --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-auth/src/lib.rs @@ -0,0 +1,65 @@ +//! Authentication plugin used by the integration suite. +//! +//! It exercises the `authenticate` extension point added in this PR. The +//! credential the client sends (as a cleartext password) drives the decision: +//! +//! - `"deny"` => [`AuthDecision::Deny`] with a reason PgDog logs +//! but never sends to the client. +//! - `"panic"` => `panic!()`. The `pgdog-plugin` bridge catches the +//! unwind inside the plugin and turns it into a Deny, so PgDog denies the +//! client and stays up. +//! - `"secret-"` => [`AuthDecision::Allow`] with no derivation; the +//! client connects to its pre-configured pool. +//! - `"impersonate:"` => [`AuthDecision::Allow`] deriving ``, +//! impersonating it via `server_role`, and asking PgDog to provision a pool. +//! - anything else => [`AuthDecision::Skip`] so PgDog tries its +//! configured password or passthrough authentication. + +use pgdog_plugin::{AuthContext, AuthDecision, AuthGrant, PdStr, Plugin, plugin}; + +plugin!(TestAuthPlugin); + +struct TestAuthPlugin; + +/// Backend credentials PgDog uses when a grant provisions a pool. The +/// integration `setup.sh` creates the `pgdog` superuser with this password, and +/// the suite's `setup.sql` grants the impersonated roles to it. A real plugin +/// would read these from its own config; hardcoding keeps the test hermetic. +const SERVER_USER: &str = "pgdog"; +const SERVER_PASSWORD: &str = "pgdog"; + +impl Plugin for TestAuthPlugin { + extern "C-unwind" fn version() -> PdStr<'static> { + env!("CARGO_PKG_VERSION").into() + } + + fn authenticate(context: AuthContext<'_>) -> AuthDecision { + let credential = &*context.credential; + + if credential == "deny" { + return AuthDecision::Deny("test deny".into()); + } + + if credential == "panic" { + panic!("test plugin panic"); + } + + if let Some(role) = credential.strip_prefix("impersonate:") { + return AuthDecision::Allow(AuthGrant { + derived_user: Some(role.to_string()), + server_role: Some(role.to_string()), + server_user: Some(SERVER_USER.to_string()), + server_password: Some(SERVER_PASSWORD.to_string()), + read_only: None, + provision: true, + }); + } + + // `secret-` allows the matching client through to its configured pool. + if credential == format!("secret-{}", &*context.user) { + return AuthDecision::Allow(AuthGrant::default()); + } + + AuthDecision::Skip + } +} diff --git a/pgdog-config/src/auth.rs b/pgdog-config/src/auth.rs index 2d938118b..19b88ffb4 100644 --- a/pgdog-config/src/auth.rs +++ b/pgdog-config/src/auth.rs @@ -49,6 +49,16 @@ pub enum AuthType { Trust, /// Plaintext password. Plain, + /// Delegate client authentication to the loaded plugins through the + /// authenticate hook. The client sends its credential in plaintext and the + /// first plugin that does not skip decides; when every plugin skips, the + /// credential is checked against the user's configured password, or + /// passthrough authentication is used for users that are not configured. + /// An explicit deny is final. + /// + /// A configured password therefore remains a way in: leave it out of + /// `users.toml` for users that must only authenticate through a plugin. + Plugin, } impl Display for AuthType { @@ -58,6 +68,7 @@ impl Display for AuthType { Self::Scram => write!(f, "scram"), Self::Trust => write!(f, "trust"), Self::Plain => write!(f, "plain"), + Self::Plugin => write!(f, "plugin"), } } } @@ -74,6 +85,10 @@ impl AuthType { pub fn trust(&self) -> bool { matches!(self, Self::Trust) } + + pub fn plugin(&self) -> bool { + matches!(self, Self::Plugin) + } } impl FromStr for AuthType { @@ -85,7 +100,67 @@ impl FromStr for AuthType { "scram" => Ok(Self::Scram), "trust" => Ok(Self::Trust), "plain" => Ok(Self::Plain), + "plugin" => Ok(Self::Plugin), _ => Err(format!("Invalid auth type: {}", s)), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_type_default_is_scram() { + assert_eq!(AuthType::default(), AuthType::Scram); + } + + #[test] + fn test_auth_type_display() { + assert_eq!(AuthType::Md5.to_string(), "md5"); + assert_eq!(AuthType::Scram.to_string(), "scram"); + assert_eq!(AuthType::Trust.to_string(), "trust"); + assert_eq!(AuthType::Plain.to_string(), "plain"); + assert_eq!(AuthType::Plugin.to_string(), "plugin"); + } + + #[test] + fn test_auth_type_from_str() { + assert_eq!("md5".parse::().unwrap(), AuthType::Md5); + assert_eq!("scram".parse::().unwrap(), AuthType::Scram); + assert_eq!("trust".parse::().unwrap(), AuthType::Trust); + assert_eq!("plain".parse::().unwrap(), AuthType::Plain); + assert_eq!("plugin".parse::().unwrap(), AuthType::Plugin); + // Case-insensitive. + assert_eq!("PLUGIN".parse::().unwrap(), AuthType::Plugin); + assert!("nonsense".parse::().is_err()); + } + + #[test] + fn test_auth_type_predicates() { + assert!(AuthType::Plugin.plugin()); + assert!(!AuthType::Scram.plugin()); + assert!(!AuthType::Plugin.scram()); + assert!(!AuthType::Plugin.md5()); + assert!(!AuthType::Plugin.trust()); + } + + #[test] + fn test_auth_type_serde_round_trip() { + // serde uses snake_case renaming; "plugin" is a single lowercase word. + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Wrapper { + auth_type: AuthType, + } + + let source = r#"auth_type = "plugin""#; + let parsed: Wrapper = toml::from_str(source).unwrap(); + assert_eq!(parsed.auth_type, AuthType::Plugin); + + let serialized = toml::to_string(&parsed).unwrap(); + assert!(serialized.contains(r#"auth_type = "plugin""#)); + + let round_tripped: Wrapper = toml::from_str(&serialized).unwrap(); + assert_eq!(round_tripped, parsed); + } +} diff --git a/pgdog-config/src/core.rs b/pgdog-config/src/core.rs index 010a4518e..d3baa643b 100644 --- a/pgdog-config/src/core.rs +++ b/pgdog-config/src/core.rs @@ -579,6 +579,31 @@ impl Config { _ => (), } + // Plugin authentication reads the client's credential in plaintext + // from the wire; only TLS protects it on the network. + if self.general.auth_type.plugin() { + if !self.general.tls_client_required { + warn!( + "consider setting \"tls_client_required\" while \"auth_type\" is \"plugin\": the client's credential travels in plaintext and only TLS protects it" + ); + } + + if self.plugins.is_empty() { + warn!( + "\"auth_type\" is \"plugin\" but no [[plugins]] are configured; every login will be denied" + ); + } + + // Plugins authenticate on the blocking pool, which is one single + // thread unless `background_workers` says otherwise, and DNS + // lookups for new server connections share it. + if self.general.background_workers == 0 { + warn!( + "\"auth_type\" is \"plugin\" and \"background_workers\" is 0, so PgDog runs one blocking thread: plugin logins run one at a time and a slow plugin also delays backend DNS lookups; raise \"background_workers\" to the number of concurrent logins you expect" + ); + } + } + if !self.general.two_phase_commit && self.rewrite.enabled { if self.rewrite.shard_key == RewriteMode::Rewrite { warn!( diff --git a/pgdog-config/src/users.rs b/pgdog-config/src/users.rs index 86210e32f..5d8451a12 100644 --- a/pgdog-config/src/users.rs +++ b/pgdog-config/src/users.rs @@ -8,6 +8,7 @@ use tracing::warn; use super::core::Config; use super::pooling::PoolerMode; use crate::RoleConfig; +use crate::sharding::QueryParserLevel; use crate::util::random_string; use schemars::JsonSchema; @@ -51,7 +52,13 @@ impl Users { pub fn check(&mut self, config: &Config) { for user in &mut self.users { if user.passwords().is_empty() { - if !config.general.passthrough_auth() && user.identity.is_none() { + // Under `auth_type = "plugin"` a user without a password is + // the normal case: the plugin authenticates the client and + // PgDog never compares a configured password. + if !config.general.passthrough_auth() + && !config.general.auth_type.plugin() + && user.identity.is_none() + { warn!( r#"user "{}" (database "{}") doesn't have a password, passthrough auth and mTLS are disabled"#, user.name, user.database, @@ -90,6 +97,30 @@ impl Users { ); } + if user.server_role.is_some() + && user.server_auth == ServerAuth::Password + && user.server_password.is_none() + && !user + .passwords() + .iter() + .any(|p| matches!(p, PasswordKind::Plain(_))) + { + warn!( + r#"user "{}" (database "{}") sets "server_role" but has no backend credential ("server_password" or a non-password "server_auth"), PgDog cannot connect to the server to impersonate it"#, + user.name, user.database + ); + } + + // The role guard needs the AST of every statement, so these pools + // parse queries whatever `query_parser` says. Say so instead of + // silently ignoring the setting. + if user.server_role.is_some() && config.general.query_parser == QueryParserLevel::Off { + warn!( + r#"user "{}" (database "{}") sets "server_role", so its queries are parsed even though "query_parser" is off"#, + user.name, user.database + ); + } + if user.vault_path.is_some() && config.vault.is_none() { warn!( r#"user "{}" (database "{}") uses Vault client auth but the [vault] section is missing from pgdog.toml"#, @@ -326,6 +357,26 @@ pub struct User { /// /// pub server_password: Option, + /// PostgreSQL role this user's backend connections assume. PgDog connects + /// as `server_user` and passes `role` in the startup packet, which makes + /// it the session's reset value: `RESET ROLE` and `DISCARD ALL` fall back + /// to it and `RESET ALL` leaves it untouched, so connection cleanup never + /// clears it. + /// + /// Clients on this pool are stopped from changing it: `SET ROLE`, + /// `RESET ROLE`, `SET SESSION AUTHORIZATION` and the `set_config(...)` + /// spellings are rejected with a permission error, and the role is + /// restored when the connection is checked back in, so a statement the + /// parser does not recognize (a `DO` block, a function body) cannot leak a + /// role to the next session. Within a session this is not an authorization + /// boundary: the real limit on what the client can reach is which roles + /// `server_user` is a member of. Queries on these pools are always parsed, + /// whatever `query_parser` is set to. + /// + /// **Note:** `server_user` needs a working backend credential of its own + /// (`server_password` or a non-password `server_auth`) and must be a + /// member of `server_role`. + pub server_role: Option, /// Backend auth mode for server connections. #[serde(default)] pub server_auth: ServerAuth, @@ -870,6 +921,40 @@ vault_refresh_percent = 60 assert!(passwords.iter().any(|p| matches!(p, PasswordKind::VaultStaticRole(s) if s == "database/static-creds/alice-role"))); } + #[test] + fn test_user_server_role_defaults_to_none() { + let source = r#" +[[users]] +name = "alice" +database = "db" +password = "secret" +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert!(user.server_role.is_none()); + } + + #[test] + fn test_user_server_role_round_trip() { + let source = r#" +[[users]] +name = "alice" +database = "db" +server_role = "analytics" +server_user = "svc" +server_password = "svc_secret" +"#; + + let users: Users = toml::from_str(source).unwrap(); + let user = users.users.first().unwrap(); + assert_eq!(user.server_role.as_deref(), Some("analytics")); + + let serialized = toml::to_string(users.users.first().unwrap()).unwrap(); + let reparsed: User = toml::from_str(&serialized).unwrap(); + assert_eq!(reparsed.server_role.as_deref(), Some("analytics")); + } + #[test] fn test_vault_static_is_external_identity() { assert!(ServerAuth::VaultStatic.is_external_identity()); diff --git a/pgdog-plugin/Cargo.toml b/pgdog-plugin/Cargo.toml index d8975e68d..f8f0faeae 100644 --- a/pgdog-plugin/Cargo.toml +++ b/pgdog-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pgdog-plugin" -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT" authors = ["Lev Kokotov "] diff --git a/pgdog-plugin/src/auth.rs b/pgdog-plugin/src/auth.rs new file mode 100644 index 000000000..3698faed8 --- /dev/null +++ b/pgdog-plugin/src/auth.rs @@ -0,0 +1,195 @@ +//! Client authentication hook. +//! +//! Plugins can validate the credential a client presents at login (a password, +//! a JWT, an API key, ...) and, on success, derive the Postgres role the +//! session should run as and how its pool should connect to the backend. +//! +//! # No ownership crosses the FFI boundary +//! +//! [`AuthDecision`] and [`AuthGrant`] are ordinary owned Rust values used by +//! plugin authors. They never cross FFI. The generated bridge keeps the owned +//! decision alive on the plugin's stack and streams each string field to the +//! host as a borrowed [`PdStr`] through the [`AuthSink`] callback, returning +//! only the POD [`AuthOutcome`] by value. This mirrors how [`crate::Config`] +//! hands borrowed strings the other way. + +use crate::PdStr; +use std::ffi::c_void; + +/// Context for a single client authentication attempt. +/// +/// All strings are borrowed and only valid for the duration of the +/// [`Plugin::authenticate`](crate::Plugin::authenticate) call. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct AuthContext<'a> { + /// User from the startup packet. + pub user: PdStr<'a>, + /// Database from the startup packet. + pub database: PdStr<'a>, + /// Credential the client presented (password, JWT, token, ...). + pub credential: PdStr<'a>, + /// Client socket address, e.g. `"10.0.0.1:54321"`. + pub client_addr: PdStr<'a>, + /// TLS certificate identity; empty when the connection has none. + pub tls_identity: PdStr<'a>, + /// Whether the connection uses TLS. + pub tls: bool, +} + +/// Backend and pool details returned when a plugin authenticates a client. +/// +/// A plain owned Rust value; it never crosses the FFI boundary. +#[derive(Debug, Default, Clone)] +pub struct AuthGrant { + /// Postgres role the pool runs as; `None` keeps the startup-packet user. + pub derived_user: Option, + /// Role assumed on the backend via the `role` startup parameter. + pub server_role: Option, + /// Backend user for an auto-provisioned pool. + pub server_user: Option, + /// Backend password for an auto-provisioned pool. + pub server_password: Option, + /// Whether the provisioned pool is read-only. `None` leaves it unset. + pub read_only: Option, + /// Auto-provision a pool for `derived_user` when one does not exist. + pub provision: bool, +} + +/// A plugin's verdict on a client authentication attempt. +#[derive(Debug, Clone)] +pub enum AuthDecision { + /// Not this plugin's credential; consult the next plugin. + Skip, + /// Authenticated; optionally derive a role and provision a pool. + Allow(AuthGrant), + /// Rejected. The reason is logged by PgDog, never sent to the client. + Deny(String), +} + +/// FFI tag for an [`AuthDecision`], returned by value across the boundary. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AuthDecisionTag { + /// Defer to the next plugin. + Skip = 0, + /// Client authenticated. + Allow = 1, + /// Client rejected. + Deny = 2, +} + +/// Which [`AuthGrant`] field (or deny reason) a plugin is reporting through the +/// [`AuthSink`] callback. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AuthField { + /// [`AuthGrant::derived_user`]. + DerivedUser = 0, + /// [`AuthGrant::server_role`]. + ServerRole = 1, + /// [`AuthGrant::server_user`]. + ServerUser = 2, + /// [`AuthGrant::server_password`]. + ServerPassword = 3, + /// [`AuthDecision::Deny`] reason. + Error = 4, +} + +/// POD result of an FFI authenticate call. +/// +/// String fields are not carried here; they are streamed to the host through +/// the [`AuthSink`] while the plugin-owned [`AuthDecision`] is still alive, so +/// no ownership crosses FFI. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct AuthOutcome { + /// The decision kind. + pub tag: AuthDecisionTag, + /// Read-only flag, encoded as [`AuthOutcome::READ_ONLY_FALSE`], + /// [`AuthOutcome::READ_ONLY_TRUE`] or [`AuthOutcome::READ_ONLY_UNSET`]. + /// Decode it with [`AuthOutcome::read_only_flag`]. + pub read_only: u8, + /// Auto-provision the derived user's pool. + pub provision: bool, +} + +impl AuthOutcome { + /// [`AuthOutcome::read_only`] code for a read-write pool. + pub const READ_ONLY_FALSE: u8 = 0; + /// [`AuthOutcome::read_only`] code for a read-only pool. + pub const READ_ONLY_TRUE: u8 = 1; + /// [`AuthOutcome::read_only`] code for a flag the plugin left unset. + pub const READ_ONLY_UNSET: u8 = 2; + + /// The neutral "defer to the next plugin" outcome. + pub(crate) const fn skip() -> Self { + Self { + tag: AuthDecisionTag::Skip, + read_only: Self::READ_ONLY_UNSET, + provision: false, + } + } + + /// Decode [`AuthOutcome::read_only`] into the flag the plugin returned in + /// its [`AuthGrant`]. + /// + /// Codes this crate does not define decode to `None`, so a plugin built + /// against a newer version cannot make the host act on a value it does not + /// understand. + pub const fn read_only_flag(&self) -> Option { + match self.read_only { + Self::READ_ONLY_FALSE => Some(false), + Self::READ_ONLY_TRUE => Some(true), + _ => None, + } + } +} + +/// Encode an `Option` read-only flag as its [`AuthOutcome::read_only`] +/// code. The inverse of [`AuthOutcome::read_only_flag`]. +pub(crate) const fn read_only_code(value: Option) -> u8 { + match value { + None => AuthOutcome::READ_ONLY_UNSET, + Some(false) => AuthOutcome::READ_ONLY_FALSE, + Some(true) => AuthOutcome::READ_ONLY_TRUE, + } +} + +/// Callback the plugin invokes to hand a borrowed field value to the host. +/// +/// The first argument is an opaque host pointer passed straight back; the host +/// side reconstructs its closure from it. Only ever called synchronously from +/// within the authenticate call, on the same thread. +/// +/// The host implementation must not panic: it is reached from the plugin +/// through an `extern "C-unwind"` call, and an unwind back out of the shared +/// library is a foreign exception the host cannot catch. See +/// [`PluginVtable::authenticate`](crate::PluginVtable::authenticate). +pub type AuthSink = extern "C-unwind" fn(*mut c_void, AuthField, PdStr<'_>); + +#[cfg(test)] +mod test { + use super::*; + + fn outcome(read_only: u8) -> AuthOutcome { + AuthOutcome { + tag: AuthDecisionTag::Allow, + read_only, + provision: false, + } + } + + #[test] + fn test_read_only_round_trip() { + for value in [None, Some(true), Some(false)] { + assert_eq!(outcome(read_only_code(value)).read_only_flag(), value); + } + } + + #[test] + fn test_unknown_read_only_code_is_unset() { + assert_eq!(outcome(7).read_only_flag(), None); + assert_eq!(AuthOutcome::skip().read_only_flag(), None); + } +} diff --git a/pgdog-plugin/src/lib.rs b/pgdog-plugin/src/lib.rs index 9bbd6ffd2..ecf4b19a5 100644 --- a/pgdog-plugin/src/lib.rs +++ b/pgdog-plugin/src/lib.rs @@ -150,6 +150,60 @@ //! } //! ``` //! +//! # Authenticating clients +//! +//! Plugins can also take part in client login. With `auth_type = "plugin"` in +//! `pgdog.toml`, PgDog asks the client for a cleartext credential and offers it +//! to each plugin in configuration order; the first one that does not return +//! [`AuthDecision::Skip`] decides the login. [`AuthDecision::Allow`] carries an +//! [`AuthGrant`], which can derive the PostgreSQL user the session runs as, +//! assume a `server_role` on the backend, supply backend credentials and +//! provision the pool. +//! +//! [`Plugin::authenticate`] differs from [`Plugin::route`] in two ways: PgDog +//! calls it from its blocking thread pool, so blocking I/O such as a request to +//! an identity provider is expected (apply your own timeout), and a panic is +//! caught and turned into a denial rather than taking the process down. +//! +//! #### Example +//! +//! ``` +//! use pgdog_plugin::prelude::*; +//! +//! pgdog_plugin::plugin!(MyPlugin); +//! +//! struct MyPlugin; +//! +//! impl Plugin for MyPlugin { +//! # extern "C-unwind" fn version() -> PdStr<'static> { +//! # env!("CARGO_PKG_VERSION").into() +//! # } +//! +//! fn authenticate(context: AuthContext<'_>) -> AuthDecision { +//! // Credentials this plugin doesn't recognize are left to the next +//! // plugin, so one deployment can mix token and password logins. +//! let Some(token) = context.credential.strip_prefix("tok_") else { +//! return AuthDecision::Skip; +//! }; +//! +//! match verify(token) { +//! Some(email) => AuthDecision::Allow(AuthGrant { +//! // Run the session as the authenticated identity. +//! derived_user: Some(email.clone()), +//! server_role: Some(email), +//! provision: true, +//! ..Default::default() +//! }), +//! None => AuthDecision::Deny("token not recognized".into()), +//! } +//! } +//! } +//! +//! # fn verify(_token: &str) -> Option { +//! # Some("alice@example.com".into()) +//! # } +//! ``` +//! //! # Enabling plugins //! //! Plugins are shared libraries, loaded by PgDog at runtime using `dlopen(3)`. If specifying only its name, make sure to place the plugin's shared library @@ -175,6 +229,7 @@ //! ``` //! +pub mod auth; mod config; pub mod context; pub mod logging; @@ -184,6 +239,7 @@ pub mod plugin; pub mod prelude; pub mod string; +pub use auth::*; pub use config::Config; pub use context::*; pub use parameters::*; diff --git a/pgdog-plugin/src/plugin.rs b/pgdog-plugin/src/plugin.rs index 571b17788..4c2ad2a8a 100644 --- a/pgdog-plugin/src/plugin.rs +++ b/pgdog-plugin/src/plugin.rs @@ -4,10 +4,12 @@ //! a safe interface to the plugin's methods. //! +use std::ffi::c_void; use std::path::Path; use crate::{ Config, Context, PdStr, Route, + auth::{AuthContext, AuthDecision, AuthField, AuthOutcome, AuthSink, read_only_code}, parameters::{Parameters, RawParameters}, }; use libloading::{Library, Symbol, library_filename}; @@ -48,6 +50,9 @@ pub struct PluginVtable { ) -> Route, /// Logging initialization. logging_init: extern "C-unwind" fn(Config<'_>), + /// Authenticate a client connection. Streams grant/deny strings to the + /// sink callback and returns the decision as POD. + authenticate: extern "C-unwind" fn(AuthContext<'_>, *mut c_void, AuthSink) -> AuthOutcome, } pub trait Plugin { @@ -115,6 +120,82 @@ pub trait Plugin { extern "C-unwind" fn logging_init(config: Config<'_>) { crate::logging::init(config) } + + /// Authenticate a client connection. + /// + /// Return [`AuthDecision::Skip`] (the default) to defer to the next plugin + /// or to PgDog's configured authentication. [`AuthDecision::Allow`] accepts + /// the client and may derive a role and provision a pool; + /// [`AuthDecision::Deny`] rejects it (the reason is logged, never sent to + /// the client). + /// + /// # Concurrency + /// + /// PgDog calls this from its blocking thread pool, so clients can be + /// authenticated concurrently and on different threads; implementations + /// must be thread-safe. Unlike [`Plugin::route`], blocking I/O is expected + /// here (an HTTP round trip to an identity provider, for example) and does + /// not stall PgDog's runtime. Apply your own timeout: a call that never + /// returns holds on to a thread in that pool. + /// + /// # Panics + /// + /// A panic is caught before it can unwind across the FFI boundary and is + /// turned into [`AuthDecision::Deny`], so a bug here rejects one client + /// instead of aborting PgDog. + fn authenticate(_context: AuthContext<'_>) -> AuthDecision { + AuthDecision::Skip + } + + #[doc(hidden)] + extern "C-unwind" fn authenticate_raw( + context: AuthContext<'_>, + sink_ctx: *mut c_void, + sink: AuthSink, + ) -> AuthOutcome { + // Catch a panic here, inside the plugin, before it can unwind across + // the FFI boundary. A panic that crosses `extern "C-unwind"` becomes a + // "foreign exception" the host cannot catch, which aborts the whole + // process; catching it here turns a buggy auth plugin into a denial + // instead. `AuthContext` is Copy, so the closure captures it by value. + let decision = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::authenticate(context))) + .unwrap_or_else(|_| AuthDecision::Deny("authentication plugin panicked".into())); + + // The owned decision lives here, on the plugin's stack, for the whole + // call. We stream each field to the host as a borrowed PdStr; nothing + // owned crosses the FFI boundary. + match decision { + AuthDecision::Skip => AuthOutcome::skip(), + AuthDecision::Deny(reason) => { + sink(sink_ctx, AuthField::Error, PdStr::from(reason.as_str())); + AuthOutcome { + tag: crate::auth::AuthDecisionTag::Deny, + read_only: AuthOutcome::READ_ONLY_UNSET, + provision: false, + } + } + AuthDecision::Allow(grant) => { + if let Some(value) = grant.derived_user.as_deref() { + sink(sink_ctx, AuthField::DerivedUser, PdStr::from(value)); + } + if let Some(value) = grant.server_role.as_deref() { + sink(sink_ctx, AuthField::ServerRole, PdStr::from(value)); + } + if let Some(value) = grant.server_user.as_deref() { + sink(sink_ctx, AuthField::ServerUser, PdStr::from(value)); + } + if let Some(value) = grant.server_password.as_deref() { + sink(sink_ctx, AuthField::ServerPassword, PdStr::from(value)); + } + AuthOutcome { + tag: crate::auth::AuthDecisionTag::Allow, + read_only: read_only_code(grant.read_only), + provision: grant.provision, + } + } + } + } } impl PluginVtable { @@ -129,6 +210,7 @@ impl PluginVtable { plugin_version: T::version, pgdog_plugin_api_version: T::plugin_api_version, logging_init: T::logging_init, + authenticate: T::authenticate_raw, } } @@ -208,4 +290,39 @@ impl PluginVtable { pub fn logging_init(&self, config: Config<'_>) { (self.logging_init)(config) } + + /// Authenticate a client. `on_field` is invoked with each grant/deny string + /// the plugin reports (borrowed for the duration of the call); the caller + /// copies what it needs into owned storage. + /// + /// # Panics + /// + /// `on_field` must not panic. It is reached from the plugin through an + /// `extern "C-unwind"` trampoline, and an unwind back out of the shared + /// library is a foreign exception that aborts the process instead of + /// being caught. Copy the value here and do fallible work with it after + /// this call returns. + pub fn authenticate( + &self, + context: AuthContext<'_>, + mut on_field: F, + ) -> AuthOutcome { + extern "C-unwind" fn trampoline( + ctx: *mut c_void, + field: AuthField, + value: PdStr<'_>, + ) { + // SAFETY: `ctx` is the `&mut F` passed below. The plugin only calls + // this synchronously, on this thread, before `authenticate` + // returns, so the borrow is live and unaliased. + let on_field = unsafe { &mut *(ctx as *mut F) }; + on_field(field, &value); + } + + (self.authenticate)( + context, + &mut on_field as *mut F as *mut c_void, + trampoline::, + ) + } } diff --git a/pgdog-plugin/src/prelude.rs b/pgdog-plugin/src/prelude.rs index a74c21cd5..5c53e099f 100644 --- a/pgdog-plugin/src/prelude.rs +++ b/pgdog-plugin/src/prelude.rs @@ -2,5 +2,6 @@ pub use crate::{ Context, ParameterFormat, PdStr, Plugin, ReadWrite, Route, Shard, + auth::{AuthContext, AuthDecision, AuthGrant}, parameters::{Parameter, ParameterValue, Parameters}, }; diff --git a/pgdog/src/auth/auth_result.rs b/pgdog/src/auth/auth_result.rs index 0be7406e8..8aa3ba9f9 100644 --- a/pgdog/src/auth/auth_result.rs +++ b/pgdog/src/auth/auth_result.rs @@ -21,6 +21,14 @@ pub(crate) enum AuthResult { NoUserOrDatabase, /// Client didn't provide password message. NoPasswordMessage, + /// An authentication plugin explicitly denied the client. + PluginDenied, + /// No authentication plugin made a decision (all skipped). The frontend + /// uses this result to fall back to password or passthrough authentication. + PluginNoDecision, + /// A plugin accepted the client but returned a grant PgDog cannot use, + /// e.g. an empty or over-long user name. + PluginInvalidGrant, } impl AuthResult { @@ -54,6 +62,11 @@ impl Display for AuthResult { } Self::NoUserOrDatabase => write!(f, "no user or database in config"), Self::NoPasswordMessage => write!(f, "client did not send password message"), + Self::PluginDenied => write!(f, "authentication plugin denied the client"), + Self::PluginNoDecision => write!(f, "no authentication plugin accepted the client"), + Self::PluginInvalidGrant => { + write!(f, "authentication plugin returned an unusable grant") + } } } } diff --git a/pgdog/src/auth/mod.rs b/pgdog/src/auth/mod.rs index 0d5f99b7e..64656cb8d 100644 --- a/pgdog/src/auth/mod.rs +++ b/pgdog/src/auth/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod auth_result; pub(crate) mod error; pub(crate) mod md5; +pub(crate) mod plugin; pub(crate) mod scram; pub(crate) mod vault; diff --git a/pgdog/src/auth/plugin.rs b/pgdog/src/auth/plugin.rs new file mode 100644 index 000000000..ea92d1233 --- /dev/null +++ b/pgdog/src/auth/plugin.rs @@ -0,0 +1,376 @@ +//! Authentication plugin driver. +//! +//! When `auth_type = "plugin"`, PgDog drives the client wire exchange (asking +//! for a cleartext password) and hands the credential to the loaded plugins. +//! Each plugin answers with an [`AuthDecision`](pgdog_plugin::AuthDecision); the +//! first plugin that does not [`Skip`](pgdog_plugin::AuthDecision::Skip) wins. +//! If every plugin skips, the frontend falls back to the user's configured +//! password or to passthrough authentication (`Client::plugin_fallback`); an +//! explicit Deny and a plugin failure are terminal. +//! +//! Plugins run inside a single [`tokio::task::spawn_blocking`] call (they may +//! block on I/O). Concurrency is bounded by the runtime's blocking pool, whose +//! size is the `background_workers` setting (`max_blocking_threads`); with the +//! default of `0` that pool has a single thread, so plugin calls run one at a +//! time. + +use pgdog_plugin::{AuthContext, AuthDecisionTag, AuthField, AuthGrant, PdStr}; +use tokio::task::spawn_blocking; +use tracing::{debug, warn}; + +use crate::auth::AuthResult; +use crate::config::config_quick; +use crate::plugin::plugins; + +/// Outcome of running the authentication plugins for a single client. +#[derive(Debug)] +pub(crate) struct PluginAuthOutcome { + /// Overall result: [`AuthResult::Ok`] on Allow, + /// [`AuthResult::PluginNoDecision`] when every plugin skipped, otherwise a + /// plugin denial. + pub(crate) result: AuthResult, + /// Grant returned by the accepting plugin (only set on Allow). + pub(crate) grant: Option, +} + +impl PluginAuthOutcome { + fn allow(grant: AuthGrant) -> Self { + Self { + result: AuthResult::Ok, + grant: Some(grant), + } + } + + fn denied() -> Self { + Self { + result: AuthResult::PluginDenied, + grant: None, + } + } + + fn invalid_grant() -> Self { + Self { + result: AuthResult::PluginInvalidGrant, + grant: None, + } + } + + fn no_decision() -> Self { + Self { + result: AuthResult::PluginNoDecision, + grant: None, + } + } +} + +/// Authenticate a client through the loaded authentication plugins. +/// +/// The owned strings are moved into a single `spawn_blocking` call that builds +/// the [`AuthContext`] (whose [`PdStr`] fields borrow them) and consults the +/// plugins. A task join failure is treated as a denial. +pub(crate) async fn authenticate( + user: String, + database: String, + credential: String, + client_addr: String, + tls_identity: Option, + tls: bool, +) -> PluginAuthOutcome { + let join = spawn_blocking(move || { + run( + &user, + &database, + &credential, + &client_addr, + tls_identity, + tls, + ) + }) + .await; + + match join { + Ok(outcome) => outcome, + Err(err) => { + warn!("authentication plugin task failed: {}", err); + PluginAuthOutcome::denied() + } + } +} + +/// Fields a plugin streamed back through the authenticate callback. +#[derive(Default)] +struct Collected { + derived_user: Option, + server_role: Option, + server_user: Option, + server_password: Option, + error: Option, +} + +/// Consult the plugins. First non-Skip decision wins. +/// +/// Plugins that do not implement `authenticate` return Skip via the trait +/// default, so no capability check is needed. Iteration order follows +/// `[[plugins]]` configuration order, matching how routing consults plugins. +fn run( + user: &str, + database: &str, + credential: &str, + client_addr: &str, + tls_identity: Option, + tls: bool, +) -> PluginAuthOutcome { + let Some(plugins) = plugins() else { + return PluginAuthOutcome::no_decision(); + }; + + // Empty identity == absent, matching the PdStr borrow convention. + let tls_identity = tls_identity.unwrap_or_default(); + + // AuthContext is Copy and only borrows these strings, which outlive the loop. + let context = AuthContext { + user: PdStr::from(user), + database: PdStr::from(database), + credential: PdStr::from(credential), + client_addr: PdStr::from(client_addr), + tls_identity: PdStr::from(tls_identity.as_str()), + tls, + }; + + for (name, plugin) in plugins { + let mut collected = Collected::default(); + let outcome = plugin.authenticate(context, |field, value| { + let slot = match field { + AuthField::DerivedUser => &mut collected.derived_user, + AuthField::ServerRole => &mut collected.server_role, + AuthField::ServerUser => &mut collected.server_user, + AuthField::ServerPassword => &mut collected.server_password, + AuthField::Error => &mut collected.error, + }; + *slot = Some(value.to_owned()); + }); + + match outcome.tag { + AuthDecisionTag::Skip => continue, + AuthDecisionTag::Allow => { + let grant = AuthGrant { + derived_user: collected.derived_user, + server_role: collected.server_role, + server_user: collected.server_user, + server_password: collected.server_password, + read_only: outcome.read_only_flag(), + provision: outcome.provision, + }; + + if let Some(problem) = invalid_grant(&grant) { + warn!( + r#"client "{}" denied: plugin "{}" returned an unusable grant: {}"#, + user, name, problem + ); + return PluginAuthOutcome::invalid_grant(); + } + + debug!(r#"client "{}" authenticated by plugin "{}""#, user, name); + return PluginAuthOutcome::allow(grant); + } + AuthDecisionTag::Deny => { + // The reason is logged but never sent to the client. `warn!` + // follows `log_connections` like the other auth failures, so a + // client retrying in a loop cannot flood the log; the reason is + // always available at debug level. + let reason = collected.error.as_deref().unwrap_or("no reason given"); + if config_quick().config.general.log_connections { + warn!( + r#"client "{}" denied by plugin "{}": {}"#, + user, name, reason + ); + } else { + debug!( + r#"client "{}" denied by plugin "{}": {}"#, + user, name, reason + ); + } + return PluginAuthOutcome::denied(); + } + } + } + + // Every plugin skipped (or there were none). Let the frontend apply its + // configured authentication fallback. + PluginAuthOutcome::no_decision() +} + +/// PostgreSQL's identifier limit, `NAMEDATALEN - 1`. +const MAX_NAME_LENGTH: usize = 63; + +/// Why PgDog cannot use a name a plugin returned, if it cannot. +/// +/// These names become pool identities, `users.toml` entries and the `role` +/// startup parameter, so they are checked before anything acts on them: an +/// empty one would create a user called `""`, padding makes two identities +/// that look identical in the config file, a control character could be +/// smuggled into it or into the startup packet, and PostgreSQL truncates +/// identifiers past 63 bytes, which would quietly map different identities +/// onto one role. +fn invalid_name(value: &str) -> Option<&'static str> { + if value.is_empty() { + Some("is empty") + } else if value.trim() != value { + Some("has leading or trailing whitespace") + } else if value.len() > MAX_NAME_LENGTH { + Some("is longer than PostgreSQL's 63-byte limit") + } else if value.contains(char::is_control) { + Some("contains a control character") + } else { + None + } +} + +/// First problem with a grant, if any. A plugin that returns one is buggy, so +/// the login is denied rather than fixed up. +fn invalid_grant(grant: &AuthGrant) -> Option { + for (field, value) in [ + ("derived_user", &grant.derived_user), + ("server_user", &grant.server_user), + ("server_role", &grant.server_role), + ] { + if let Some(value) = value.as_deref() + && let Some(problem) = invalid_name(value) + { + return Some(format!("{field} {problem}")); + } + } + + // Not an identifier, but it is stored in users.toml and sent to the + // server, so it cannot be empty or carry control characters either. + if let Some(password) = grant.server_password.as_deref() + && (password.is_empty() || password.contains(char::is_control)) + { + return Some("server_password is empty or contains a control character".into()); + } + + None +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn test_all_skip_is_no_decision() { + // With no plugins loaded, the frontend receives PluginNoDecision and + // applies its configured authentication fallback. + let outcome = authenticate( + "alice".into(), + "pgdog".into(), + "secret".into(), + "127.0.0.1:5432".into(), + None, + false, + ) + .await; + + assert_eq!(outcome.result, AuthResult::PluginNoDecision); + assert!(outcome.grant.is_none()); + assert!(!outcome.result.is_ok()); + } + + #[test] + fn test_run_no_plugins_returns_no_decision() { + let outcome = run("bob", "pgdog", "secret", "127.0.0.1:5432", None, false); + assert_eq!(outcome.result, AuthResult::PluginNoDecision); + } + + #[test] + fn test_invalid_grant_rejects_unusable_names() { + for (field, value, expected) in [ + ("derived_user", "", "derived_user is empty"), + ( + "derived_user", + " alice", + "derived_user has leading or trailing whitespace", + ), + ( + "server_user", + "svc\u{0}", + "server_user contains a control character", + ), + ( + "server_role", + "report\ning", + "server_role contains a control character", + ), + ] { + let mut grant = AuthGrant::default(); + let slot = match field { + "derived_user" => &mut grant.derived_user, + "server_user" => &mut grant.server_user, + _ => &mut grant.server_role, + }; + *slot = Some(value.into()); + + assert_eq!(invalid_grant(&grant).as_deref(), Some(expected)); + } + + let grant = AuthGrant { + derived_user: Some("a".repeat(MAX_NAME_LENGTH + 1)), + ..Default::default() + }; + assert_eq!( + invalid_grant(&grant).as_deref(), + Some("derived_user is longer than PostgreSQL's 63-byte limit") + ); + + let grant = AuthGrant { + server_password: Some(String::new()), + ..Default::default() + }; + assert!(invalid_grant(&grant).is_some()); + } + + #[test] + fn test_invalid_grant_accepts_a_usable_grant() { + let grant = AuthGrant { + derived_user: Some("alice@example.com".into()), + server_role: Some("alice@example.com".into()), + server_user: Some("pgdog".into()), + server_password: Some("hunter2".into()), + read_only: Some(true), + provision: true, + }; + assert_eq!(invalid_grant(&grant), None); + + // A grant that sets nothing at all is the common case: the client keeps + // the startup user and an existing pool. + assert_eq!(invalid_grant(&AuthGrant::default()), None); + + // 63 bytes is the limit, not one less. + let grant = AuthGrant { + derived_user: Some("a".repeat(MAX_NAME_LENGTH)), + ..Default::default() + }; + assert_eq!(invalid_grant(&grant), None); + } + + #[test] + fn test_outcome_constructors() { + let grant = AuthGrant { + derived_user: Some("reporting".into()), + provision: true, + ..Default::default() + }; + let allow = PluginAuthOutcome::allow(grant); + assert!(allow.result.is_ok()); + assert_eq!( + allow.grant.and_then(|grant| grant.derived_user).as_deref(), + Some("reporting") + ); + + let denied = PluginAuthOutcome::denied(); + assert_eq!(denied.result, AuthResult::PluginDenied); + assert!(!denied.result.is_ok()); + + let none = PluginAuthOutcome::no_decision(); + assert_eq!(none.result, AuthResult::PluginNoDecision); + } +} diff --git a/pgdog/src/auth/scram/mod.rs b/pgdog/src/auth/scram/mod.rs index 85bd66cb8..b1af5f4b5 100644 --- a/pgdog/src/auth/scram/mod.rs +++ b/pgdog/src/auth/scram/mod.rs @@ -33,3 +33,72 @@ pub(crate) fn generate_hash( BASE64_STANDARD.encode(server_key.as_ref()), ) } + +/// Verify a plaintext password against a PostgreSQL SCRAM-SHA-256 verifier +/// (`SCRAM-SHA-256$iterations:salt$StoredKey:ServerKey`). +/// +/// Used when the client already sent its password in cleartext (the plugin +/// fallback, `auth_type = "plain"`) and a SCRAM exchange cannot be started on +/// the same connection. Malformed verifiers never match. This runs PBKDF2 and +/// is CPU-bound: callers on the async runtime should push it to a blocking +/// thread. +pub(crate) fn verify_password(password: &str, verifier: &str) -> bool { + use std::num::NonZeroU32; + + use base64::prelude::*; + + let Some(rest) = verifier.strip_prefix("SCRAM-SHA-256$") else { + return false; + }; + let Some((iterations_and_salt, _keys)) = rest.split_once('$') else { + return false; + }; + let Some((iterations, salt)) = iterations_and_salt.split_once(':') else { + return false; + }; + let Ok(iterations) = iterations.parse::() else { + return false; + }; + let Some(iterations) = NonZeroU32::new(iterations) else { + return false; + }; + let Ok(salt) = BASE64_STANDARD.decode(salt) else { + return false; + }; + + let candidate = generate_hash(password, iterations, &salt); + crate::util::constant_time_eq(candidate.as_bytes(), verifier.as_bytes()) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use super::{generate_hash, verify_password}; + + #[test] + fn verifies_plaintext_against_scram_verifier() { + let verifier = generate_hash( + "correct-password", + NonZeroU32::new(4096).expect("iterations are non-zero"), + b"pgdog_test_salt!", + ); + + assert!(verify_password("correct-password", &verifier)); + assert!(!verify_password("wrong-password", &verifier)); + } + + #[test] + fn rejects_invalid_scram_verifier() { + assert!(!verify_password("password", "not-a-scram-verifier")); + assert!(!verify_password( + "password", + "SCRAM-SHA-256$0:c2FsdA==$stored:server" + )); + // md5 verifiers are not SCRAM verifiers and never match. + assert!(!verify_password( + "password", + "md532b5f5d0e0a8c1a1b2c3d4e5f60718293" + )); + } +} diff --git a/pgdog/src/backend/auth/azure_workload_identity.rs b/pgdog/src/backend/auth/azure_workload_identity.rs index 6ed677b4d..d63c4f4ad 100644 --- a/pgdog/src/backend/auth/azure_workload_identity.rs +++ b/pgdog/src/backend/auth/azure_workload_identity.rs @@ -59,6 +59,7 @@ mod tests { passwords: vec![], database_number: 0, server_auth: ServerAuth::AzureWorkloadIdentity, + server_role: None, server_iam_region: None, server_iam_assume_role: None, vault_path: Default::default(), diff --git a/pgdog/src/backend/auth/rds_iam.rs b/pgdog/src/backend/auth/rds_iam.rs index 0eca0e494..53c838778 100644 --- a/pgdog/src/backend/auth/rds_iam.rs +++ b/pgdog/src/backend/auth/rds_iam.rs @@ -157,6 +157,7 @@ mod tests { passwords: vec![], database_number: 0, server_auth: ServerAuth::RdsIam, + server_role: None, server_iam_region: Some("us-east-1".into()), server_iam_assume_role: None, vault_path: Default::default(), diff --git a/pgdog/src/backend/auth/vault.rs b/pgdog/src/backend/auth/vault.rs index 18e72912b..69da39ec1 100644 --- a/pgdog/src/backend/auth/vault.rs +++ b/pgdog/src/backend/auth/vault.rs @@ -185,6 +185,7 @@ mod tests { user: "testuser".into(), passwords: vec![], server_auth: Default::default(), + server_role: None, server_iam_region: None, server_iam_assume_role: None, vault_path: vault_path.map(Into::into), diff --git a/pgdog/src/backend/databases.rs b/pgdog/src/backend/databases.rs index 2ebf1a552..dd42fdc3e 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -231,6 +231,82 @@ pub(crate) fn add(user: ConfigUser) -> Result { } } +/// Provision or complete a user during plugin authentication. +/// +/// Unlike [`add`], this does not compare a client password: the plugin has +/// already authenticated the client, and the credential (e.g. a JWT) differs +/// on every login. A configured `users.toml` entry is never overwritten, +/// only missing backend credentials are filled in. +/// +/// The entry, including any `server_password` the plugin supplied, is stored in +/// the in-memory configuration only: `users.toml` on disk is never written, and +/// a configuration reload drops whatever was provisioned here, exactly like the +/// passthrough path in [`add`]. Names in the grant are validated by +/// [`crate::auth::plugin`] before they reach this function. +pub(crate) fn add_authenticated(user: ConfigUser) -> Result { + fn store(user: ConfigUser) -> Result<(), Error> { + let _lock = lock(); + let mut config = (*config()).clone(); + config.users.add_or_replace(user); + set(config)?; + Ok(()) + } + + let config = config(); + let existing = config.users.find(&user); + + if let Some(mut existing) = existing { + // Never overwrite a configured entry; only fill gaps so a partially + // configured user still gets usable backend credentials. + let mut changed = false; + if existing.server_user.is_none() && user.server_user.is_some() { + existing.server_user = user.server_user.clone(); + changed = true; + } + if existing.server_password.is_none() && user.server_password.is_some() { + existing.server_password = user.server_password.clone(); + changed = true; + } + if existing.server_role.is_none() && user.server_role.is_some() { + existing.server_role = user.server_role.clone(); + changed = true; + } else if let (Some(configured), Some(granted)) = (&existing.server_role, &user.server_role) + && configured != granted + { + // The plugin asked to impersonate a different role than the one + // configured; the configured value wins, so queries will not run + // as the authenticated identity. + warn!( + r#"user "{}" on database "{}": configured server_role "{}" overrides plugin-granted server_role "{}""#, + existing.name, existing.database, configured, granted + ); + } + if existing.read_only.is_none() && user.read_only.is_some() { + existing.read_only = user.read_only; + changed = true; + } + + if changed { + debug!( + r#"filling backend-credential gaps for user "{}" on database "{}""#, + existing.name, existing.database + ); + store(existing)?; + reload_from_existing()?; + } + + Ok(AuthResult::Ok) + } else { + debug!( + r#"provisioning user "{}" on database "{}" via plugin authentication"#, + user.name, user.database + ); + store(user)?; + reload_from_existing()?; + Ok(AuthResult::Ok) + } +} + /// Swap database configs between source and destination. /// Both databases keep their names, but their configs (host, port, etc.) are exchanged. /// User database references are also swapped. @@ -457,8 +533,17 @@ impl Databases { } // Launch all clusters + let plugin_auth = config().config.general.auth_type.plugin(); for cluster in self.all().values() { - if cluster.passwords().is_empty() && cluster.identity().is_none() { + // A cluster needs a way for clients to authenticate to it and a way + // for it to authenticate to Postgres. Without a client password or + // an mTLS identity nobody can log in, unless plugin authentication + // is on: then a plugin vouches for every login and the pool only + // needs its own backend credentials (a server password or an + // external identity). A pool with neither stays disabled until a + // plugin Allow supplies credentials through `add_authenticated`. + let plugin_pool = plugin_auth && cluster.has_backend_credentials(); + if cluster.passwords().is_empty() && cluster.identity().is_none() && !plugin_pool { warn!( r#"disabling pool for user "{}" and database "{}", password not set"#, cluster.user(), @@ -858,6 +943,79 @@ mod tests { assert_eq!(found.unwrap().password, Some("new_pass".to_string())); } + #[tokio::test] + async fn test_add_authenticated_fills_server_role_gap() { + setup_config( + PassthroughAuth::Disabled, + vec![ConfigUser { + name: "alice@example.com".to_string(), + database: "db1".to_string(), + server_user: Some("service".to_string()), + server_password: Some("secret".to_string()), + ..Default::default() + }], + ); + + let granted = ConfigUser { + name: "alice@example.com".to_string(), + database: "db1".to_string(), + server_role: Some("alice@example.com".to_string()), + ..Default::default() + }; + assert!( + add_authenticated(granted) + .expect("add_authenticated") + .is_ok() + ); + + let config = crate::config::config(); + let found = config + .users + .find(&make_user("alice@example.com", None)) + .expect("user exists"); + // The grant filled the missing role; configured credentials stayed. + assert_eq!(found.server_role.as_deref(), Some("alice@example.com")); + assert_eq!(found.server_user.as_deref(), Some("service")); + assert_eq!(found.server_password.as_deref(), Some("secret")); + + // The rebuilt pool impersonates the role via the startup packet. + let cluster = databases() + .cluster(("alice@example.com", "db1")) + .expect("cluster exists"); + assert_eq!(cluster.server_role(), Some("alice@example.com")); + } + + #[tokio::test] + async fn test_add_authenticated_keeps_configured_server_role() { + setup_config( + PassthroughAuth::Disabled, + vec![ConfigUser { + name: "bob".to_string(), + database: "db1".to_string(), + server_role: Some("analytics".to_string()), + ..Default::default() + }], + ); + + let granted = ConfigUser { + name: "bob".to_string(), + database: "db1".to_string(), + server_role: Some("bob".to_string()), + ..Default::default() + }; + assert!( + add_authenticated(granted) + .expect("add_authenticated") + .is_ok() + ); + + let config = crate::config::config(); + let found = config + .users + .find(&make_user("bob", None)) + .expect("user exists"); + assert_eq!(found.server_role.as_deref(), Some("analytics")); + } #[tokio::test] async fn test_add_existing_user_wrong_password_no_change_allowed() { setup_config( diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 906c41d18..286c0fe07 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -30,6 +30,10 @@ pub(crate) struct Address { /// Server auth mode for backend connections. #[serde(default)] pub(crate) server_auth: ServerAuth, + /// PostgreSQL role backend connections assume via the `role` startup + /// parameter, from `User.server_role`. + #[serde(default)] + pub(crate) server_role: Option, /// Optional IAM region override. pub(crate) server_iam_region: Option, /// Optional IAM role ARN to assume before minting the RDS IAM token, for @@ -101,6 +105,7 @@ impl Address { .collect() }, server_auth, + server_role: user.server_role.clone(), server_iam_region: user.server_iam_region.clone(), server_iam_assume_role: user.server_iam_assume_role.clone(), vault_path: user.server_vault_path.clone(), @@ -220,6 +225,7 @@ impl Address { passwords: vec!["pgdog".into()], database_name: "pgdog".into(), server_auth: ServerAuth::Password, + server_role: None, server_iam_region: None, server_iam_assume_role: None, vault_path: None, diff --git a/pgdog/src/backend/pool/cleanup.rs b/pgdog/src/backend/pool/cleanup.rs index df077a4ca..024f672c5 100644 --- a/pgdog/src/backend/pool/cleanup.rs +++ b/pgdog/src/backend/pool/cleanup.rs @@ -23,6 +23,34 @@ static ALL: Lazy> = Lazy::new(|| vec!["DISCARD ALL"].into_iter().map(Query::new).collect()); static NONE: Lazy> = Lazy::new(Vec::new); +/// `RESET ROLE` restores the role from the startup packet, which on a pool +/// with `server_role` is the impersonated role, not "no role". +/// +/// `RESET ALL` does not do this: `role` is `GUC_NO_RESET_ALL` in PostgreSQL +/// and is skipped. Only `DISCARD ALL` covers it, through its implicit +/// `SET SESSION AUTHORIZATION DEFAULT`. +static ROLE: Lazy> = Lazy::new(|| vec![Query::new("RESET ROLE")]); +static DIRTY_ROLE: Lazy> = Lazy::new(|| { + let mut queries = DIRTY.clone(); + queries.push(Query::new("RESET ROLE")); + queries +}); +static PREPARED_ROLE: Lazy> = Lazy::new(|| { + let mut queries = PREPARED.clone(); + queries.push(Query::new("RESET ROLE")); + queries +}); + +/// Whether the connection belongs to a pool that impersonates a `server_role` +/// and therefore has to have that role restored before it is reused. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum RoleReset { + /// The pool does not impersonate a role. + NotNeeded, + /// Restore the startup-packet role on check-in. + Needed, +} + /// Queries used to clean up server connections after /// client modifications. pub(crate) struct Cleanup { @@ -60,12 +88,27 @@ impl std::fmt::Display for Cleanup { impl Cleanup { /// New cleanup operation. pub(crate) fn new(guard: &Guard, server: &mut Server) -> Self { + // A pool that impersonates a role has to put that role back before the + // connection serves another session. The query parser rejects the + // statements that change `role`, but it cannot see every spelling (a + // `DO` block, a function body, a computed `set_config` name), and + // `RESET ALL` skips `role`, so without this an escaped role would + // outlive the session that set it. + let role = if server.addr().server_role.is_some() { + RoleReset::Needed + } else { + RoleReset::NotNeeded + }; + let mut clean = if guard.reset { + // `DISCARD ALL` already restores the startup-packet role. Self::all() } else if server.dirty() { - Self::parameters() + Self::parameters(role) } else if server.schema_changed() { - Self::prepared_statements() + Self::prepared_statements(role) + } else if role == RoleReset::Needed { + Self::role() } else { Self::none() }; @@ -81,23 +124,37 @@ impl Cleanup { } /// Cleanup prepared statements. - pub(crate) fn prepared_statements() -> Self { + pub(super) fn prepared_statements(role: RoleReset) -> Self { Self { - queries: &*PREPARED, + queries: match role { + RoleReset::NotNeeded => &*PREPARED, + RoleReset::Needed => &*PREPARED_ROLE, + }, deallocate: true, ..Default::default() } } /// Cleanup parameters. - pub(crate) fn parameters() -> Self { + pub(super) fn parameters(role: RoleReset) -> Self { Self { - queries: &*DIRTY, + queries: match role { + RoleReset::NotNeeded => &*DIRTY, + RoleReset::Needed => &*DIRTY_ROLE, + }, dirty: true, ..Default::default() } } + /// Restore the impersonated role and nothing else. + pub(super) fn role() -> Self { + Self { + queries: &*ROLE, + ..Default::default() + } + } + /// Cleanup everything. pub(crate) fn all() -> Self { Self { diff --git a/pgdog/src/backend/pool/cluster.rs b/pgdog/src/backend/pool/cluster.rs index d7fcc9b8d..41ec37faf 100644 --- a/pgdog/src/backend/pool/cluster.rs +++ b/pgdog/src/backend/pool/cluster.rs @@ -81,6 +81,7 @@ pub(crate) struct Cluster { sharding_lookup_timeout: Duration, regex_parser: RegexParser, identity: Option, + server_role: Option, tls_client_certificate_required: bool, #[debug(skip)] schema_loader: Box, @@ -132,6 +133,7 @@ impl Default for Cluster { sharding_lookup_timeout: Duration::from_millis(General::sharding_lookup_timeout()), regex_parser: Default::default(), identity: Default::default(), + server_role: Default::default(), tls_client_certificate_required: Default::default(), schema_loader: Default::default(), canonical_oids: Default::default(), @@ -222,6 +224,7 @@ pub(crate) struct ClusterConfig<'a> { regex_parser_limit: usize, pub_sub_enabled: bool, identity: &'a Option, + server_role: Option, tls_client_certificate_required: bool, schema_cache: SchemaCache, canonicalize_oids: bool, @@ -292,6 +295,7 @@ impl<'a> ClusterConfig<'a> { regex_parser_limit: general.regex_parser_limit, pub_sub_enabled: general.pub_sub_enabled(), identity: &user.identity, + server_role: user.server_role.clone(), tls_client_certificate_required: user.tls_client_certificate_required.unwrap_or(true), schema_cache, canonicalize_oids: general.canonicalize_type_information, @@ -339,6 +343,7 @@ impl Cluster { regex_parser_limit, pub_sub_enabled, identity, + server_role, tls_client_certificate_required, schema_cache, canonicalize_oids, @@ -412,6 +417,7 @@ impl Cluster { sharding_lookup_timeout: Duration::from_millis(sharding_lookup_timeout), regex_parser: RegexParser::new(regex_parser_limit, query_parser), identity: identity.clone(), + server_role, tls_client_certificate_required, schema_loader: Box::new(schema_loader::FromServer), canonical_oids, @@ -499,6 +505,22 @@ impl Cluster { self.identity.as_deref() } + /// PostgreSQL role backend connections impersonate through the `role` + /// startup parameter. + pub(crate) fn server_role(&self) -> Option<&str> { + self.server_role.as_deref() + } + + /// Whether this cluster's pools can authenticate to PostgreSQL on their + /// own: a backend password on any pool, or an external identity + /// (IAM, Vault, ...) as `server_auth`. + pub(crate) fn has_backend_credentials(&self) -> bool { + self.shards.iter().flat_map(Shard::pools).any(|pool| { + let addr = pool.addr(); + !addr.passwords.is_empty() || addr.server_auth.is_external_identity() + }) + } + /// This user must present a client TLS certificate when connecting over TLS. pub(crate) fn tls_client_certificate_required(&self) -> bool { self.tls_client_certificate_required @@ -603,6 +625,12 @@ impl Cluster { /// Use the query parser. pub(crate) fn use_query_parser(&self, request: &ClientRequest) -> bool { + // Every statement has to be inspected for role changes; the regex + // fast path would let `SELECT set_config('role', ...)` through. + if self.server_role.is_some() { + return true; + } + match self.query_parser() { QueryParserLevel::Off => false, QueryParserLevel::On => true, @@ -932,6 +960,11 @@ mod test { cluster } + /// Impersonate `role` on this cluster's backend connections. + pub(crate) fn set_server_role(&mut self, role: &str) { + self.server_role = Some(role.into()); + } + pub(crate) fn new_test_single_primary(config: &ConfigAndUsers) -> Cluster { let identifier = Arc::new(DatabaseUser { user: "pgdog".into(), @@ -1211,4 +1244,26 @@ mod test { cluster.query_parser = QueryParserLevel::Off; assert!(!cluster.use_query_parser(&req)); } + + #[test] + fn test_use_query_parser_server_role() { + // A plain SELECT never trips the regex fast path on its own. + let req = ClientRequest::from(vec![ + Query::new("SELECT set_config('role', 'postgres', false)").into(), + ]); + + let mut cluster = Cluster::new_test_single_primary(&config()); + cluster.query_parser = QueryParserLevel::Off; + assert!(!cluster.use_query_parser(&req)); + + cluster.set_server_role("analytics"); + for level in [ + QueryParserLevel::Off, + QueryParserLevel::SessionControl, + QueryParserLevel::Auto, + ] { + cluster.query_parser = level; + assert!(cluster.use_query_parser(&req), "{level:?}"); + } + } } diff --git a/pgdog/src/backend/pool/pool_impl.rs b/pgdog/src/backend/pool/pool_impl.rs index a3a800f4c..d566fbaf0 100644 --- a/pgdog/src/backend/pool/pool_impl.rs +++ b/pgdog/src/backend/pool/pool_impl.rs @@ -486,6 +486,18 @@ impl Pool { }); } + // Impersonation role from `User.server_role`. Sent in the startup + // packet so it becomes the session's reset value: `RESET ROLE` and + // `DISCARD ALL` fall back to it and `RESET ALL` leaves it alone + // (`role` is GUC_NO_RESET_ALL), so cleanup between checkouts never + // clears it. + if let Some(role) = &self.inner.addr.server_role { + params.push(Parameter { + name: "role".into(), + value: role.as_str().into(), + }); + } + ServerOptions { params, pool_id: self.id(), diff --git a/pgdog/src/backend/pool/test/mod.rs b/pgdog/src/backend/pool/test/mod.rs index 4201a3c71..4d89408a2 100644 --- a/pgdog/src/backend/pool/test/mod.rs +++ b/pgdog/src/backend/pool/test/mod.rs @@ -1260,3 +1260,90 @@ async fn test_move_conns_to_does_not_pause_destination_when_source_is_not_paused destination.shutdown(); } + +#[test] +fn test_server_options_role() { + let pool = Pool::new(&PoolConfig { + address: Address { + server_role: Some("analytics".into()), + ..Address::new_test() + }, + config: Config::default(), + }); + let options = pool.server_options(ConnectReason::default()); + let role = options + .params + .iter() + .find(|p| p.name == "role") + .expect("role startup parameter"); + assert_eq!(role.value.as_str(), Some("analytics")); + + let pool = Pool::new(&PoolConfig { + address: Address::new_test(), + config: Config::default(), + }); + assert!( + pool.server_options(ConnectReason::default()) + .params + .iter() + .all(|p| p.name != "role") + ); +} + +/// A role a client escaped to must not survive on the pooled connection. +/// +/// The query parser rejects the statements that change `role`, but it cannot +/// see every spelling, and `RESET ALL` skips `role`, so the pool restores it +/// explicitly on check-in. `pgdog1` and `pgdog2` are created by +/// `integration/setup.sh`. +#[tokio::test] +async fn test_server_role_restored_on_checkin() { + crate::logger(); + + let pool = Pool::new(&PoolConfig { + address: Address { + server_role: Some("pgdog1".into()), + ..Address::new_test() + }, + config: Config { + max: 1, + min: 0, + ..Config::default() + }, + }); + pool.launch(); + + let id = { + let mut guard = pool.get(&Request::default()).await.unwrap(); + let role: Vec = guard.fetch_all("SELECT current_user").await.unwrap(); + assert_eq!(role[0], "pgdog1"); + + // Talk to the server directly, the way a `DO` block or a function body + // reaches `SET ROLE` without the parser seeing it. + guard.execute("SET ROLE pgdog2").await.unwrap(); + let role: Vec = guard.fetch_all("SELECT current_user").await.unwrap(); + assert_eq!(role[0], "pgdog2"); + + guard.id() + }; + + // Cleanup runs in a recovery task spawned when the guard is dropped. + for _ in 0..100 { + if pool.lock().idle() == 1 { + break; + } + sleep(Duration::from_millis(20)).await; + } + + let mut guard = pool.get(&Request::default()).await.unwrap(); + assert_eq!( + guard.id(), + id, + "connection was replaced, nothing was reused" + ); + let role: Vec = guard.fetch_all("SELECT current_user").await.unwrap(); + assert_eq!(role[0], "pgdog1", "escaped role leaked to the next session"); + + drop(guard); + pool.shutdown(); +} diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 9d35b194c..0ef74d9a8 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -15,7 +15,7 @@ use tracing::{Level as LogLevel, debug, enabled, error, info, trace, warn}; use super::{ClientRequest, Error, PreparedStatements}; use crate::auth::AuthResult; -use crate::auth::{md5, scram::Server}; +use crate::auth::{md5, plugin, scram::Server}; use crate::backend::maintenance_mode; use crate::backend::pool::stats::MemoryStats; use crate::backend::{ @@ -32,7 +32,7 @@ use crate::net::messages::{ use crate::net::{MessageBuffer, ProtocolMessage, Stream, parameter::Parameters}; use crate::state::State; use crate::stats::memory::MemoryUsage; -use crate::util::{safe_timeout, user_database_from_params}; +use crate::util::{maybe_spawn_blocking, safe_timeout, user_database_from_params}; pub(crate) mod query_engine; pub(crate) mod sticky; @@ -218,22 +218,24 @@ impl Client { } } - AuthType::Plain => { + // `Plugin` only reaches here on the admin path (plugin auth is + // driven separately in `login`), where it behaves like `Plain`: + // compare against the configured admin password. + AuthType::Plain | AuthType::Plugin => { stream .send_flush(&Authentication::ClearTextPassword) .await?; let response = stream.read().await?; let response = Password::from_bytes(response.to_bytes())?; - let is_match = response.password().is_some_and(|provided| { - passwords.iter().any(|p| { - crate::util::constant_time_eq(p.as_str().as_bytes(), provided.as_bytes()) - }) - }); - - if is_match { - AuthResult::Ok - } else { - AuthResult::NoPasswordMatch + match response.password() { + Some(provided) => { + Self::check_cleartext_password_offloaded( + passwords.to_vec(), + provided.to_string(), + ) + .await? + } + None => AuthResult::NoPasswordMatch, } } @@ -243,10 +245,150 @@ impl Client { Ok(result) } + /// Verify a credential already received through a cleartext password + /// exchange. This is used when all authentication plugins skip, because a + /// second MD5 or SCRAM exchange cannot be started on the same connection. + fn check_cleartext_password(passwords: &[PasswordKind], provided: &str) -> AuthResult { + if passwords.is_empty() { + return AuthResult::NoPasswordConfig; + } + + let is_match = passwords.iter().any(|password| match password { + PasswordKind::Plain(password) => { + crate::util::constant_time_eq(password.as_bytes(), provided.as_bytes()) + } + PasswordKind::Hashed(verifier) => { + crate::auth::scram::verify_password(provided, verifier) + } + // Resolved to `Plain` by `vault::resolve_passwords` before we get + // here; an unresolved entry never matches. + PasswordKind::VaultStaticRole(_) => false, + }); + + if is_match { + AuthResult::Ok + } else { + AuthResult::NoPasswordMatch + } + } + + /// [`check_cleartext_password`](Self::check_cleartext_password), run off + /// the async runtime when `background_workers` are enabled: a SCRAM + /// verifier costs a PBKDF2 key derivation (see #1483). + async fn check_cleartext_password_offloaded( + passwords: Vec, + provided: String, + ) -> Result { + Ok( + maybe_spawn_blocking(move || Self::check_cleartext_password(&passwords, &provided)) + .await?, + ) + } + + /// Fall back after every authentication plugin returned `Skip`. + /// + /// A user present in `users.toml` is authenticated against its configured + /// password (plain or `password_hash`), checked in place rather than + /// through a second wire-protocol exchange, which cannot be started on a + /// connection that already answered a cleartext request. A configured user + /// with no client password at all is plugin-only: it is denied instead of + /// being handed to passthrough, which would accept and store whatever + /// credential arrived. Passthrough only applies to users that are not in + /// the configuration, exactly as it does without plugins. + /// + /// Note that this makes every configured password a second way in while + /// `auth_type = "plugin"` is set: a client whose credential no plugin + /// claims can still log in with it. Users that must only ever authenticate + /// through a plugin are configured without a client password. + async fn plugin_fallback( + stream: &Stream, + user: &str, + database: &str, + credential: &str, + passthrough: bool, + client_ca_configured: bool, + ) -> Result { + if let Ok(cluster) = databases::databases().cluster((user, database)) { + if let Some(identity) = cluster.identity() { + return Ok(if stream.tls_identity() == Some(identity) { + AuthResult::Ok + } else { + AuthResult::NoIdentity + }); + } + + if (ClientCertificateCheck { + client_ca_configured, + is_tls: stream.is_tls(), + required: cluster.tls_client_certificate_required(), + presented: stream.tls_client_certificate(), + }) + .rejected() + { + return Ok(AuthResult::NoClientCertificate); + } + + // A user that is in the configuration decides here, whatever + // passthrough would do with the credential. + // + // With no configured client password the user is plugin-only: + // deny, so passthrough cannot turn an account backed by service + // credentials into one that accepts its first arbitrary password. + // With one, verify against it: `databases::add` compares only the + // `password` field, so a user configured with `password_hash` + // alone would otherwise have any credential accepted and stored. + if cluster.passwords().is_empty() { + return Ok(AuthResult::NoPasswordConfig); + } + + let passwords = crate::auth::vault::resolve_passwords(cluster.passwords()).await; + return Self::check_cleartext_password_offloaded(passwords, credential.to_string()) + .await; + } else if !passthrough { + return Ok(AuthResult::NoUserOrDatabase); + } + + // Same call as the passthrough branch of `login`: the credential is + // stored and Postgres verifies it on the first server connection. + let user = config::User { + name: user.to_string(), + database: database.to_string(), + password: Some(credential.to_string()), + ..Default::default() + }; + Ok(databases::add(user)?) + } + + /// Drop the `role` startup parameter (`role=...` or `options=-c role=...`) + /// for pools that impersonate a fixed `server_role`. Left in place, it + /// would be synced to the server as `SET "role"` on every checkout and + /// bypass the query-level guard. + fn strip_startup_role(params: &mut Parameters, user: &str, database: &str, addr: SocketAddr) { + if params.get("role").is_none() { + return; + } + + let fixed_role = databases::databases() + .cluster((user, database)) + .map(|cluster| cluster.server_role().is_some()) + .unwrap_or(false); + + if !fixed_role { + return; + } + + if let Some(role) = params.remove("role") { + warn!( + r#"user "{}" on database "{}" requested startup role {} on a pool with a fixed server_role, ignoring [{}]"#, + user, database, role, addr + ); + } + } + /// Create new frontend client from the given TCP stream. async fn login( mut stream: Stream, - params: Parameters, + mut params: Parameters, addr: SocketAddr, config: Arc, protocol_version: ProtocolVersion, @@ -258,6 +400,13 @@ impl Client { } let (user, database) = user_database_from_params(¶ms); + // Owned copies: `params` is edited below (startup `role` stripping) + // while these names stay in use. + let (user, database) = (user.to_owned(), database.to_owned()); + let (user, database) = (user.as_str(), database.as_str()); + + Self::strip_startup_role(&mut params, user, database, addr); + let admin = database == config.config.admin.name && config.config.admin.user == user; let admin_password = &config.config.admin.password; let auth_type = &config.config.general.auth_type; @@ -270,6 +419,10 @@ impl Client { // could never be satisfied. let client_ca_configured = config.config.general.tls_client_ca_certificate.is_some(); + // Username a plugin derived for the client (e.g. impersonation). When + // set, it replaces `user` for all backend operations. + let mut derived_user: Option = None; + // Check if we need to ask the client for its password in plaintext // because we don't actually have it configured. // @@ -280,6 +433,78 @@ impl Client { // map, so authenticate directly against the configured admin password. let passwords = [PasswordKind::Plain(admin_password.clone())]; Self::check_password(&mut stream, user, auth_type, &passwords).await? + } else if auth_type.plugin() { + // Plugin authentication: request a cleartext credential from the + // client (same wire flow as passthrough), then hand it to the + // authentication plugins. Allow can derive a user and provision a + // pool; Deny rejects the client; all-Skip falls back to configured + // password or passthrough authentication. + stream + .send_flush(&Authentication::ClearTextPassword) + .await?; + let password = stream.read().await?; + let password = Password::from_bytes(password.to_bytes())?; + if let Some(credential) = password.password() { + let tls_identity = stream.tls_identity().map(|id| id.to_string()); + let outcome = plugin::authenticate( + user.to_string(), + database.to_string(), + credential.to_string(), + addr.to_string(), + tls_identity, + stream.is_tls(), + ) + .await; + + match outcome.result { + AuthResult::Ok => { + if let Some(grant) = outcome.grant { + derived_user = grant.derived_user.clone(); + let effective = derived_user.as_deref().unwrap_or(user); + + // Reconcile the grant with the derived user's pool: + // fill backend-credential gaps (e.g. `server_role` + // for impersonation) on an existing entry, or + // provision a new pool when the plugin asked for + // it. Without a pool and without `provision`, + // `Connection::new` below fails the login. + let exists = databases::databases() + .cluster((effective, database)) + .is_ok(); + if exists || grant.provision { + let granted = config::User { + name: effective.to_string(), + database: database.to_string(), + server_user: grant.server_user.clone(), + server_password: grant.server_password.clone(), + server_role: grant.server_role.clone(), + read_only: grant.read_only, + ..Default::default() + }; + databases::add_authenticated(granted)?; + } + } + AuthResult::Ok + } + // Every plugin skipped: verify the credential against the + // configured password, or use passthrough authentication. + AuthResult::PluginNoDecision => { + Self::plugin_fallback( + &stream, + user, + database, + credential, + passthrough, + client_ca_configured, + ) + .await? + } + // Deny and plugin failures are terminal. + result => result, + } + } else { + AuthResult::NoPasswordMessage + } } else if passthrough { // Get the password. We always need it because we need to check if // it's current and hasn't been changed. @@ -333,19 +558,34 @@ impl Client { } }; + // When a plugin derived a user, use that name for `Connection::new`, + // error responses, and log lines; otherwise use the startup username. + // Note that comms and stats keep using the startup-packet parameters + // (and thus the original startup user). + let effective_user = derived_user.as_deref().unwrap_or(user); + if !auth_result.is_ok() { if log_connections { warn!( r#"user "{}" and database "{}" auth error: {}"#, - user, database, auth_result + effective_user, database, auth_result ); } - stream.fatal(ErrorResponse::auth(user, database)).await?; + stream + .fatal(ErrorResponse::auth(effective_user, database)) + .await?; return Ok(None); } else { stream.send(&Authentication::Ok).await?; } + // A plugin Allow may have derived another user or given the pool a + // `server_role` just now; the startup `role` check above ran against + // the original user, so repeat it against the pool actually used. + if auth_type.plugin() && !admin { + Self::strip_startup_role(&mut params, effective_user, database, addr); + } + // Check if the pooler is shutting down. // // We do this late because we don't want to give away anything about the @@ -357,11 +597,13 @@ impl Client { return Ok(None); } - let mut conn = match Connection::new(user, database, admin) { + let mut conn = match Connection::new(effective_user, database, admin) { Ok(conn) => conn, Err(err) => { debug!("connection error: {}", err); - stream.fatal(ErrorResponse::auth(user, database)).await?; + stream + .fatal(ErrorResponse::auth(effective_user, database)) + .await?; return Ok(None); } }; @@ -377,7 +619,7 @@ impl Client { addr ); stream - .fatal(ErrorResponse::connection(user, database)) + .fatal(ErrorResponse::connection(effective_user, database)) .await?; return Ok(None); } else { @@ -400,7 +642,11 @@ impl Client { user, database, addr, - if passthrough { + // `auth_type = "plugin"` is what authenticated this client + // even when passthrough is also enabled for other users. + if auth_type.plugin() && !admin { + "plugin".into() + } else if passthrough { "passthrough".into() } else { auth_type.to_string() diff --git a/pgdog/src/frontend/client/query_engine/context.rs b/pgdog/src/frontend/client/query_engine/context.rs index 7f9af0cb1..c0aa10097 100644 --- a/pgdog/src/frontend/client/query_engine/context.rs +++ b/pgdog/src/frontend/client/query_engine/context.rs @@ -110,4 +110,15 @@ impl<'a> QueryEngineContext<'a> { pub(crate) fn in_error(&self) -> bool { self.transaction.map(|t| t.error()).unwrap_or_default() } + + /// Put an open transaction into the aborted state, like Postgres does + /// after an error: the client has to end it before running anything else. + pub(crate) fn abort_transaction(&mut self) { + self.transaction = self.transaction.map(|transaction| match transaction { + TransactionType::ReadOnly | TransactionType::ErrorReadOnly => { + TransactionType::ErrorReadOnly + } + _ => TransactionType::ErrorReadWrite, + }); + } } diff --git a/pgdog/src/frontend/client/query_engine/mod.rs b/pgdog/src/frontend/client/query_engine/mod.rs index 9196bcf8b..8e331b381 100644 --- a/pgdog/src/frontend/client/query_engine/mod.rs +++ b/pgdog/src/frontend/client/query_engine/mod.rs @@ -284,6 +284,12 @@ impl QueryEngine { Command::Discard { target, extended } => { self.discard(context, *target, *extended).await? } + Command::RoleLocked { name } => { + // Postgres aborts the transaction on this error too. + context.abort_transaction(); + self.error_response(context, ErrorResponse::role_locked(name)) + .await?; + } Command::Split(queries) => return Ok(Self::build_simple_split(queries)), } diff --git a/pgdog/src/frontend/client/test/auth.rs b/pgdog/src/frontend/client/test/auth.rs index f973b232a..a9bcdf957 100644 --- a/pgdog/src/frontend/client/test/auth.rs +++ b/pgdog/src/frontend/client/test/auth.rs @@ -1,12 +1,15 @@ //! Client authentication tests. -use pgdog_config::{AuthType, PassthroughAuth}; +use std::num::NonZeroU32; use crate::{ + auth::scram, config::{config, set}, expect_message, + frontend::Client, net::{Authentication, ErrorResponse, Parameters, Password}, }; +use pgdog_config::{AuthType, PassthroughAuth, users::PasswordKind}; use super::SpawnedClient; @@ -29,6 +32,20 @@ async fn login_admin(password: &str) -> SpawnedClient { client } +/// Connect to a regular database user and answer the cleartext credential +/// request used by plugin authentication. +async fn login_user(user: &str, password: &str) -> SpawnedClient { + let mut params = Parameters::default(); + params.insert("user", user); + params.insert("database", "pgdog"); + + let mut client = SpawnedClient::new_with_login(params).await; + let request = expect_message!(client.read().await, Authentication); + assert!(matches!(request, Authentication::ClearTextPassword)); + client.send(Password::new_password(password)).await; + client +} + /// Admin connections must be authenticated against the admin password even /// when passthrough auth is enabled. Regression test for the passthrough /// branch running first and accepting any password for the admin database. @@ -56,3 +73,158 @@ async fn test_admin_password_checked_with_passthrough_auth() { client.read_until('Z').await; client.join().await; } + +#[test] +fn test_cleartext_password_supports_plain_and_scram_verifiers() { + let verifier = scram::generate_hash( + "hashed-password", + NonZeroU32::new(4096).expect("iterations are non-zero"), + b"pgdog_test_salt!", + ); + let passwords = [ + PasswordKind::Plain("plain-password".into()), + PasswordKind::Hashed(verifier), + ]; + + assert_eq!( + Client::check_cleartext_password(&passwords, "plain-password"), + crate::auth::AuthResult::Ok + ); + assert_eq!( + Client::check_cleartext_password(&passwords, "hashed-password"), + crate::auth::AuthResult::Ok + ); + assert_eq!( + Client::check_cleartext_password(&passwords, "wrong-password"), + crate::auth::AuthResult::NoPasswordMatch + ); + assert_eq!( + Client::check_cleartext_password(&[], "anything"), + crate::auth::AuthResult::NoPasswordConfig + ); +} + +#[tokio::test] +async fn test_plugin_skip_falls_back_to_configured_password() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + set(cfg).unwrap(); + + let mut client = login_user("pgdog", "pgdog").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; +} + +#[tokio::test] +async fn test_plugin_skip_rejects_wrong_configured_password() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + set(cfg).unwrap(); + + let mut client = login_user("pgdog", "wrong-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; +} + +/// A configured user keeps being authenticated against its own password when +/// passthrough is enabled; passthrough does not take over the login. +#[tokio::test] +async fn test_plugin_skip_checks_configured_password_with_passthrough_enabled() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + set(cfg).unwrap(); + + let mut client = login_user("pgdog", "pgdog").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; + + let mut client = login_user("pgdog", "not-the-configured-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; +} + +/// Passthrough still applies to users that are not in the configuration: +/// `pgdog1` is created by `integration/setup.sh` and is not in the test config. +#[tokio::test] +async fn test_plugin_skip_falls_back_to_passthrough_for_unconfigured_user() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + set(cfg).unwrap(); + + let mut client = login_user("pgdog1", "pgdog").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; +} + +/// `databases::add` only ever compares the `password` field, so a user +/// configured with `password_hash` alone used to have any credential accepted +/// (and stored) once passthrough was enabled. The fallback verifies against +/// the configured password itself instead of deferring to passthrough. +#[tokio::test] +async fn test_plugin_skip_verifies_hashed_password_under_passthrough() { + crate::logger(); + crate::config::load_test(); + + let verifier = scram::generate_hash( + "hashed-password", + NonZeroU32::new(4096).expect("iterations are non-zero"), + b"pgdog_test_salt!", + ); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + cfg.users.users[0].password = None; + cfg.users.users[0].password_hash = Some(verifier); + cfg.users.users[0].server_password = Some("pgdog".into()); + set(cfg).unwrap(); + crate::backend::databases::reload_from_existing().unwrap(); + + let mut client = login_user("pgdog", "arbitrary-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; + + let mut client = login_user("pgdog", "hashed-password").await; + let response = expect_message!(client.read().await, Authentication); + assert!(matches!(response, Authentication::Ok)); + client.read_until('Z').await; +} + +#[tokio::test] +async fn test_plugin_skip_does_not_bootstrap_password_for_plugin_only_user() { + crate::logger(); + crate::config::load_test(); + + let mut cfg = (*config()).clone(); + cfg.config.general.auth_type = AuthType::Plugin; + cfg.config.general.passthrough_auth = PassthroughAuth::EnabledPlain; + cfg.users.users[0].password = None; + cfg.users.users[0].server_password = Some("pgdog".into()); + set(cfg).unwrap(); + crate::backend::databases::reload_from_existing().unwrap(); + + let mut client = login_user("pgdog", "arbitrary-password").await; + let error = ErrorResponse::try_from(client.read().await).unwrap(); + assert_eq!(error.code, "28000"); + client.join().await; +} diff --git a/pgdog/src/frontend/router/parser/command.rs b/pgdog/src/frontend/router/parser/command.rs index adf96688b..911349178 100644 --- a/pgdog/src/frontend/router/parser/command.rs +++ b/pgdog/src/frontend/router/parser/command.rs @@ -64,6 +64,13 @@ pub(crate) enum Command { }, Unlisten(String), UniqueId, + /// A client tried to change the backend role (`SET ROLE`, `RESET ROLE`, + /// `SET SESSION AUTHORIZATION`, `set_config('role', ...)`, ...) on a pool + /// that impersonates a fixed `server_role`. Rejected with a 42501 error; + /// the session stays alive. `name` is the offending variable. + RoleLocked { + name: String, + }, } impl Command { diff --git a/pgdog/src/frontend/router/parser/query/mod.rs b/pgdog/src/frontend/router/parser/query/mod.rs index 6ffde6ede..3a12d3682 100644 --- a/pgdog/src/frontend/router/parser/query/mod.rs +++ b/pgdog/src/frontend/router/parser/query/mod.rs @@ -340,6 +340,15 @@ impl QueryParser { .run()?; } + // Pools that impersonate a fixed `server_role` must not let clients + // change it. Checked before multi-statement handling so + // `SELECT 1; SET ROLE x` is caught as well. + if context.router_context.cluster.server_role().is_some() + && let Some(name) = set_config::role_escape_target(stmts) + { + return Ok(Command::RoleLocked { name }); + } + if let Some(command) = self.check_multi_query_statement(statement, context)? { return Ok(command); } diff --git a/pgdog/src/frontend/router/parser/query/set_config.rs b/pgdog/src/frontend/router/parser/query/set_config.rs index 7aad92b0b..89aadfa54 100644 --- a/pgdog/src/frontend/router/parser/query/set_config.rs +++ b/pgdog/src/frontend/router/parser/query/set_config.rs @@ -24,6 +24,38 @@ impl QueryParser { } } +const ROLE_ESCAPE_PARAMS: [&str; 2] = ["role", "session_authorization"]; + +/// Canonical name of a role-changing variable, matched case-insensitively. +fn role_escape_param(name: &str) -> Option<&'static str> { + ROLE_ESCAPE_PARAMS + .iter() + .copied() + .find(|param| name.eq_ignore_ascii_case(param)) +} + +/// Name of the first role-changing variable (`role`, `session_authorization`) +/// a statement list sets or resets, via `SET`/`RESET` or `set_config(...)`. +pub(super) fn role_escape_target(stmts: &pg_raw_parse::StmtList) -> Option { + for node in stmts.stmts() { + let name = match node { + Node::VariableSetStmt(stmt) => stmt.name().and_then(role_escape_param), + Node::SelectStmt(stmt) => extract_set_config(stmt) + .and_then(|fcall| fcall.args().first()) + .and_then(parse_config_name) + .as_deref() + .and_then(role_escape_param), + _ => None, + }; + + if let Some(name) = name { + return Some(name.to_string()); + } + } + + None +} + /// Returns None if the arguments could not be parsed fn parse_args(fcall: &nodes::FuncCall) -> Option { let name = parse_config_name(fcall.args().first()?)?; @@ -62,3 +94,53 @@ fn parse_is_local(arg: Node<'_>) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn role_escape(query: &str) -> Option { + let statements = pg_raw_parse::parse(query).expect("parse query"); + role_escape_target(&statements) + } + + #[test] + fn detects_role_escape_statements() { + for (query, expected) in [ + ("SET ROLE reporting", "role"), + ("SET LOCAL ROLE reporting", "role"), + ("RESET ROLE", "role"), + ( + "SET SESSION AUTHORIZATION reporting", + "session_authorization", + ), + ("RESET SESSION AUTHORIZATION", "session_authorization"), + ("SELECT 1; SET ROLE reporting", "role"), + ( + "SELECT set_config('role', 'report' || 'ing', false)", + "role", + ), + ( + "SELECT pg_catalog.set_config('ROLE', 'reporting', true)", + "role", + ), + ( + "SELECT set_config('session_authorization', current_user, false)", + "session_authorization", + ), + ] { + assert_eq!(role_escape(query).as_deref(), Some(expected), "{query}"); + } + } + + #[test] + fn allows_session_reset_to_startup_defaults() { + assert_eq!(role_escape("RESET ALL"), None); + assert_eq!(role_escape("DISCARD ALL"), None); + assert_eq!(role_escape("SET statement_timeout TO 1"), None); + assert_eq!( + role_escape("SELECT set_config('work_mem', '8MB', false)"), + None + ); + } +} diff --git a/pgdog/src/frontend/router/parser/query/test/mod.rs b/pgdog/src/frontend/router/parser/query/test/mod.rs index d16050f5a..043c421d4 100644 --- a/pgdog/src/frontend/router/parser/query/test/mod.rs +++ b/pgdog/src/frontend/router/parser/query/test/mod.rs @@ -35,6 +35,7 @@ pub(crate) mod test_rr; pub(crate) mod test_schema_sharding; pub(crate) mod test_search_path; pub(crate) mod test_select; +pub(crate) mod test_server_role; pub(crate) mod test_session_control; pub(crate) mod test_set; pub(crate) mod test_sharding; diff --git a/pgdog/src/frontend/router/parser/query/test/setup.rs b/pgdog/src/frontend/router/parser/query/test/setup.rs index e41a2de2c..4c9f4835a 100644 --- a/pgdog/src/frontend/router/parser/query/test/setup.rs +++ b/pgdog/src/frontend/router/parser/query/test/setup.rs @@ -89,6 +89,12 @@ impl QueryParserTest { me } + /// Impersonate a fixed role on the cluster's backend connections. + pub(crate) fn with_server_role(mut self, role: &str) -> Self { + self.cluster.set_server_role(role); + self + } + /// Set whether we're in a transaction. pub(crate) fn in_transaction(mut self, in_tx: bool) -> Self { self.transaction = if in_tx { diff --git a/pgdog/src/frontend/router/parser/query/test/test_server_role.rs b/pgdog/src/frontend/router/parser/query/test/test_server_role.rs new file mode 100644 index 000000000..ab9dc1179 --- /dev/null +++ b/pgdog/src/frontend/router/parser/query/test/test_server_role.rs @@ -0,0 +1,90 @@ +use pgdog_config::QueryParserLevel; + +use crate::{config::config, frontend::Command}; + +use super::setup::*; + +/// Pool impersonating a fixed role. The query parser is switched off to +/// check that `server_role` forces full parsing on its own. +fn setup() -> QueryParserTest { + let mut config = (*config()).clone(); + config.config.general.query_parser = QueryParserLevel::Off; + QueryParserTest::new_single_primary(&config).with_server_role("analytics") +} + +#[test] +fn test_rejects_role_changes() { + for (query, expected) in [ + ("SET ROLE postgres", "role"), + ("SET LOCAL ROLE postgres", "role"), + ("RESET ROLE", "role"), + ( + "SET SESSION AUTHORIZATION postgres", + "session_authorization", + ), + ("RESET SESSION AUTHORIZATION", "session_authorization"), + ("SELECT 1; SET ROLE postgres", "role"), + ("SET statement_timeout TO 1; RESET ROLE", "role"), + ("SELECT set_config('role', 'postgres', false)", "role"), + ( + "SELECT pg_catalog.set_config('role', 'postgres', true)", + "role", + ), + ( + "SELECT set_config('session_authorization', 'postgres', false)", + "session_authorization", + ), + ] { + let mut test = setup(); + let command = test.execute(vec![Query::new(query).into()]); + assert!( + matches!(&command, Command::RoleLocked { name } if name == expected), + "{query}: expected RoleLocked({expected}), got {command:#?}", + ); + } +} + +#[test] +fn test_rejects_role_changes_extended() { + let mut test = setup(); + let command = test.execute(vec![ + Parse::new_anonymous("SET ROLE postgres").into(), + Sync.into(), + ]); + assert!( + matches!(&command, Command::RoleLocked { name } if name == "role"), + "got {command:#?}", + ); +} + +#[test] +fn test_allows_other_session_state() { + for query in [ + "RESET ALL", + "DISCARD ALL", + "SET statement_timeout TO 1", + "RESET statement_timeout", + "SELECT set_config('work_mem', '8MB', false)", + "SELECT current_user", + ] { + let mut test = setup(); + let command = test.execute(vec![Query::new(query).into()]); + assert!( + !matches!(command, Command::RoleLocked { .. }), + "{query}: got {command:#?}", + ); + } +} + +#[test] +fn test_role_changes_allowed_without_server_role() { + let mut config = (*config()).clone(); + config.config.general.query_parser = QueryParserLevel::On; + let mut test = QueryParserTest::new_single_primary(&config); + + let command = test.execute(vec![Query::new("SET ROLE postgres").into()]); + assert!( + matches!(command, Command::Set { .. }), + "expected Command::Set, got {command:#?}", + ); +} diff --git a/pgdog/src/net/messages/error_response.rs b/pgdog/src/net/messages/error_response.rs index 526d541a4..cb7aba872 100644 --- a/pgdog/src/net/messages/error_response.rs +++ b/pgdog/src/net/messages/error_response.rs @@ -106,6 +106,19 @@ impl ErrorResponse { } } + pub(crate) fn role_locked(name: &str) -> ErrorResponse { + ErrorResponse { + severity: "ERROR".into(), + code: "42501".into(), + message: format!( + "\"SET {}\" is not allowed: this connection impersonates a fixed role", + name + ), + routine: Some("client::QueryEngine::set".into()), + ..Default::default() + } + } + pub(crate) fn omni_write_with_directive() -> ErrorResponse { ErrorResponse { severity: "ERROR".into(), diff --git a/pgdog/src/net/parameter.rs b/pgdog/src/net/parameter.rs index 1edec4bcb..fdc2c6c03 100644 --- a/pgdog/src/net/parameter.rs +++ b/pgdog/src/net/parameter.rs @@ -217,6 +217,18 @@ impl Parameters { result } + /// Remove a parameter for good (unlike [`Self::reset`], which is + /// transactional), returning its value. + pub(crate) fn remove(&mut self, name: &str) -> Option { + let result = self.params.remove(&name.to_lowercase()); + + if result.is_some() { + self.hash = Self::compute_hash(&self.params); + } + + result + } + /// Recompute hash when params are cleared. pub(crate) fn clear(&mut self) { self.params.clear(); @@ -1053,6 +1065,26 @@ mod test { ); } + #[test] + fn test_remove() { + let mut params = Parameters::default(); + params.insert("role", "postgres"); + params.insert("search_path", "public"); + let before = params.hash; + + assert_eq!( + params.remove("ROLE"), + Some(ParameterValue::String("postgres".into())) + ); + assert_eq!(params.get("role"), None); + assert_ne!(params.hash, before); + assert_eq!(params.remove("role"), None); + assert_eq!( + params.get("search_path"), + Some(&ParameterValue::String("public".into())) + ); + } + #[test] fn test_reset_all_rollback_restores_all() { let mut params = Parameters::default(); diff --git a/pgdog/src/plugin/mod.rs b/pgdog/src/plugin/mod.rs index e60c943a3..8ca0aa769 100644 --- a/pgdog/src/plugin/mod.rs +++ b/pgdog/src/plugin/mod.rs @@ -1,17 +1,17 @@ //! pgDog plugins. +use indexmap::IndexMap; use once_cell::sync::OnceCell; use pgdog_config::{Config, LogFormat}; use pgdog_plugin::libloading; use pgdog_plugin::libloading::Library; use pgdog_plugin::{Config as PdConfig, PdStr, PluginVtable}; use semver::Version; -use std::collections::HashMap; use tokio::time::Instant; use tracing::{debug, error, info, warn}; -static LIBS: OnceCell> = OnceCell::new(); -pub(crate) static PLUGINS: OnceCell> = OnceCell::new(); +static LIBS: OnceCell>> = OnceCell::new(); +pub(crate) static PLUGINS: OnceCell> = OnceCell::new(); // Compare semantic versions by major and minor only (ignore patch/bugfix). fn same_major_minor(a: &str, b: &str) -> bool { @@ -39,7 +39,7 @@ pub(crate) fn load(config: &Config) -> Result<(), libloading::Error> { let libs = plugins .iter() - .filter_map(|plugin| { + .map(|plugin| { PluginVtable::library(&plugin.name) .map_err(|err| error!("plugin \"{}\" failed to load: {:#?}", plugin.name, err)) .ok() @@ -51,8 +51,12 @@ pub(crate) fn load(config: &Config) -> Result<(), libloading::Error> { let rustc_version = pgdog_plugin::RUSTC_VERSION; let pgdog_plugin_api_version = pgdog_plugin::VERSION; - let plugin_libs = plugins.iter().enumerate().filter_map(|(i, plugin)| { - if let Some(lib) = LIBS.get().unwrap().get(i) { + let Some(libs) = LIBS.get() else { + return Ok(()); + }; + + let plugin_libs = plugins.iter().zip(libs).filter_map(|(plugin, lib)| { + if let Some(lib) = lib { let now = Instant::now(); let Some(plugin_lib) = PluginVtable::load(lib) else { warn!( @@ -139,7 +143,7 @@ pub(crate) fn shutdown() { } /// Get all loaded plugins. -pub(crate) fn plugins() -> Option<&'static HashMap> { +pub(crate) fn plugins() -> Option<&'static IndexMap> { PLUGINS.get() } diff --git a/plugins/README.md b/plugins/README.md index 733ccec34..5865a8976 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,6 +4,15 @@ This directory contains plugins that ship with PgDog and are built by original a ## Plugins +### `pgdog-google-auth` + +Authenticates PostgreSQL clients with Google OAuth 2.0 access tokens, including +tokens printed by `gcloud auth print-access-token`. The plugin validates tokens +with Google's `tokeninfo` endpoint and can restrict access by Google account, +domain, OAuth audience, and scope. + +See the [`pgdog-google-auth` documentation](pgdog-google-auth/README.md). + ### `pgdog-example-plugin` Example plugin that can be used as reference by the community. It currently records diff --git a/plugins/pgdog-google-auth/Cargo.toml b/plugins/pgdog-google-auth/Cargo.toml new file mode 100644 index 000000000..33ce6e841 --- /dev/null +++ b/plugins/pgdog-google-auth/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pgdog-google-auth" +version = "0.1.0" +edition.workspace = true +description = "PgDog authentication plugin for Google OAuth 2.0 access tokens." +license = "AGPL-3.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +once_cell = "1" +parking_lot.workspace = true +pgdog-plugin.workspace = true +reqwest = { workspace = true, features = ["blocking", "form", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json.workspace = true +thiserror = "2" +toml = "0.8" +tracing = "0.1" +url = "2" + +[dev-dependencies] +tempfile = "3.23" diff --git a/plugins/pgdog-google-auth/README.md b/plugins/pgdog-google-auth/README.md new file mode 100644 index 000000000..5edc629ac --- /dev/null +++ b/plugins/pgdog-google-auth/README.md @@ -0,0 +1,156 @@ +# Google access-token authentication + +`pgdog-google-auth` lets PostgreSQL clients authenticate to PgDog with Google +OAuth 2.0 access tokens, including tokens produced by +[`gcloud auth print-access-token`](https://docs.cloud.google.com/sdk/gcloud/reference/auth/print-access-token). + +The plugin sends the token to Google's HTTPS `tokeninfo` endpoint. It checks the +token expiry and can enforce verified email, account, domain, OAuth audience, +and scope restrictions. The token is never forwarded to PostgreSQL. + +> [!WARNING] +> Access tokens are bearer credentials. Require TLS between clients and PgDog. +> The plugin enforces TLS by default. + +## Build + +The main PgDog container build includes the plugin at +`/usr/lib/libpgdog_google_auth.so`. To build it locally: + +```bash +cargo build --release -p pgdog-google-auth +``` + +## Configure PgDog + +Enable plugin authentication and point PgDog at the plugin configuration: + +```toml +[general] +auth_type = "plugin" +tls_client_required = true + +[[plugins]] +name = "pgdog_google_auth" +config = "google-auth.toml" +``` + +Start from [`config.example.toml`](config.example.toml). Two settings are not +optional: + +- `allowed_audiences` — the OAuth client IDs whose access tokens may log in. + The plugin refuses to load without it. Google will introspect *any* valid + access token, including one a user granted to an unrelated application, and + report their verified email; without an audience check that token logs them + into your database. The `aud`/`azp` claim is what ties a token to your own + client, so list the client IDs you issue tokens from. For the tokens + `gcloud auth print-access-token` mints, that is the gcloud client ID: + + ```bash + # The "aud" of a token the CLI issues. + curl -s -d "access_token=$(gcloud auth print-access-token)" \ + https://oauth2.googleapis.com/tokeninfo | jq -r .aud + ``` + +- `allowed_domains` or `allowed_emails` — which verified identities may + authenticate at all. + +To let selected users authenticate with PostgreSQL passwords, enable PgDog's +password or passthrough fallback: + +```toml +# pgdog.toml +[general] +auth_type = "plugin" +passthrough_auth = "enabled" +tls_client_required = true +``` + +With `username_claim = "email"`, `strip_email_domain = false`, and +`require_user_match = true`, the plugin claims only full email startup users +matching `allowed_domains` or `allowed_emails`. A non-email user such as +`postgres` returns `Skip` without sending its password to Google. Invalid Google +tokens for claimed email users still return `Deny`, so they cannot downgrade to +password authentication. For true backend passthrough, do not define the +non-email user in `users.toml`. + +When `require_user_match = false`, `strip_email_domain = true`, or +`username_claim = "user_id"`, PgDog cannot route by the startup email namespace. +The plugin therefore claims the credential and preserves fail-closed behavior. + +By default, the verified Google email becomes the PostgreSQL user and must +match the startup user, compared case-insensitively. For example: + +```bash +account="$(gcloud config get-value account)" +PGPASSWORD="$(gcloud auth print-access-token)" \ + psql "host=pgdog.example.com port=6432 dbname=app user=${account} sslmode=require" +``` + +For preconfigured pools, add the derived user to `users.toml`: + +```toml +[[users]] +name = "alice@example.com" +database = "app" +server_user = "pgdog_service" +# Render server_password here from the approved secret manager. +``` + +With `impersonate = true` (the default), the plugin's grant fills in +`server_role` at login with the derived Google identity, so queries run as +`alice@example.com` rather than as `pgdog_service`. The PostgreSQL role must +already exist and be granted to the service account (see below); otherwise the +backend refuses the `role` startup parameter and the login fails. An explicit +`server_role` in `users.toml` takes precedence over the grant; PgDog logs a +warning when they differ. + +Do not commit backend passwords. Render `users.toml` from the approved secret +manager or use auto-provisioning with `server_password_env`. + +## Auto-provision users + +The plugin can create a pool after it validates a token: + +```toml +allowed_domains = ["example.com"] +provision = true +impersonate = true +server_user = "pgdog_service" +server_password_env = "PGDOG_GOOGLE_AUTH_SERVER_PASSWORD" +``` + +Inject `PGDOG_GOOGLE_AUTH_SERVER_PASSWORD` into the PgDog process from the +approved secret manager. PgDog uses `server_user` to connect to PostgreSQL and +sets the derived Google identity as `server_role`. + +The PostgreSQL role must already exist, and the service account must be allowed +to assume it: + +```sql +GRANT "alice@example.com" TO pgdog_service; +``` + +Set `strip_email_domain = true` if PostgreSQL roles use the email local part. +Set `username_claim = "user_id"` if stable numeric Google account IDs are +preferred. + +## Security and operations + +- The token is sent only to the configured `tokeninfo_url`, as a POST form + body rather than a query string, so it does not appear in access logs along + the way. Redirects are disabled to prevent credential forwarding. +- `tokeninfo_url` must use HTTPS. Plain HTTP is accepted only for loopback test + servers. +- Request errors are sanitized so logs do not include the token-bearing URL. +- `allowed_audiences` is mandatory; see [Configure PgDog](#configure-pgdog). + Tokens minted for any other OAuth client are rejected. +- `timeout_ms` bounds token validation. PgDog's `background_workers` setting + caps concurrent blocking authentication calls; with its default of 0 there is + a single blocking thread, so logins wait behind each other and a slow + introspection call also delays backend DNS lookups. Raise it to the number of + concurrent logins you expect. +- Google may rate-limit token introspection. Connection pooling reduces calls + because validation happens once per client connection. +- A Google or network outage prevents new logins. Existing database sessions + remain active. diff --git a/plugins/pgdog-google-auth/config.example.toml b/plugins/pgdog-google-auth/config.example.toml new file mode 100644 index 000000000..438d53742 --- /dev/null +++ b/plugins/pgdog-google-auth/config.example.toml @@ -0,0 +1,40 @@ +# Google OAuth 2.0 token validation endpoint. +tokeninfo_url = "https://oauth2.googleapis.com/tokeninfo" +timeout_ms = 5000 + +# Access tokens are credentials. Keep this enabled outside isolated loopback +# tests so the PostgreSQL password exchange is encrypted. +require_tls = true + +# Derive the PostgreSQL user from "email" or "user_id". +username_claim = "email" +strip_email_domain = false +require_user_match = true +require_verified_email = true + +# Required: OAuth client IDs ("aud"/"azp") whose access tokens may log in. +# The plugin refuses to start without this. Google introspects any valid access +# token, so without it a token a user granted to an unrelated application +# authenticates that user against this database. +allowed_audiences = ["1234567890-abcdef.apps.googleusercontent.com"] + +# Restrict which Google identities may authenticate. If both lists are set, +# matching either an exact email or a domain is sufficient. +allowed_domains = ["example.com"] +allowed_emails = [] + +# With the defaults above, startup users outside the allowed full-email +# namespace (for example, "postgres") return Skip without sending their +# credential to Google. PgDog can then use password or passthrough fallback. + +# Optional scope restriction. +required_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + +# Existing users.toml entries do not need provisioning. To create pools for +# validated Google identities, enable provision and inject the backend password +# into PgDog's environment from the approved secret manager. +provision = false +impersonate = true +# server_user = "pgdog_service" +# server_password_env = "PGDOG_GOOGLE_AUTH_SERVER_PASSWORD" +# read_only = false diff --git a/plugins/pgdog-google-auth/src/config.rs b/plugins/pgdog-google-auth/src/config.rs new file mode 100644 index 000000000..5afce7e82 --- /dev/null +++ b/plugins/pgdog-google-auth/src/config.rs @@ -0,0 +1,410 @@ +use std::{env, fs, path::Path, time::Duration}; + +use reqwest::{Url, blocking::Client, redirect::Policy}; +use serde::Deserialize; +use thiserror::Error; +use url::Host; + +const DEFAULT_TOKENINFO_URL: &str = "https://oauth2.googleapis.com/tokeninfo"; +const DEFAULT_TIMEOUT_MS: u64 = 5_000; +const MIN_TIMEOUT_MS: u64 = 100; +const MAX_TIMEOUT_MS: u64 = 60_000; + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum UsernameClaim { + #[default] + Email, + UserId, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Settings { + pub(crate) tokeninfo_url: String, + pub(crate) timeout_ms: u64, + pub(crate) require_tls: bool, + pub(crate) username_claim: UsernameClaim, + pub(crate) strip_email_domain: bool, + pub(crate) require_user_match: bool, + pub(crate) require_verified_email: bool, + pub(crate) allowed_audiences: Vec, + pub(crate) allowed_domains: Vec, + pub(crate) allowed_emails: Vec, + pub(crate) required_scopes: Vec, + pub(crate) provision: bool, + pub(crate) impersonate: bool, + pub(crate) server_user: Option, + pub(crate) server_password_env: Option, + pub(crate) read_only: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + tokeninfo_url: DEFAULT_TOKENINFO_URL.into(), + timeout_ms: DEFAULT_TIMEOUT_MS, + require_tls: true, + username_claim: UsernameClaim::Email, + strip_email_domain: false, + require_user_match: true, + require_verified_email: true, + allowed_audiences: Vec::new(), + allowed_domains: Vec::new(), + allowed_emails: Vec::new(), + required_scopes: Vec::new(), + provision: false, + impersonate: true, + server_user: None, + server_password_env: None, + read_only: None, + } + } +} + +pub(crate) struct RuntimeConfig { + pub(crate) settings: Settings, + pub(crate) endpoint: Url, + pub(crate) client: Client, + pub(crate) server_password: Option, +} + +impl RuntimeConfig { + pub(crate) fn load(path: Option<&Path>) -> Result { + let settings = match path { + Some(path) => { + let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.display().to_string(), + source, + })?; + toml::from_str(&contents).map_err(|source| ConfigError::Parse { + path: path.display().to_string(), + source, + })? + } + None => Settings::default(), + }; + + Self::from_settings(settings) + } + + pub(crate) fn from_settings(mut settings: Settings) -> Result { + if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&settings.timeout_ms) { + return Err(ConfigError::Invalid(format!( + "timeout_ms must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}" + ))); + } + + normalize_list(&mut settings.allowed_audiences, "allowed_audiences", false)?; + // Without an audience allow-list, any Google access token for any + // OAuth client logs its owner in: a token the user granted to an + // unrelated application would authenticate them against this database + // (token confusion). The audience is what binds a token to this + // deployment, so there is no usable default. + if settings.allowed_audiences.is_empty() { + return Err(ConfigError::Invalid( + "allowed_audiences is required: list the OAuth client IDs (the \"aud\"/\"azp\" claims) whose access tokens may log in".into(), + )); + } + normalize_list(&mut settings.required_scopes, "required_scopes", false)?; + normalize_list(&mut settings.allowed_domains, "allowed_domains", true)?; + normalize_list(&mut settings.allowed_emails, "allowed_emails", true)?; + + let endpoint = validate_endpoint(&settings.tokeninfo_url)?; + let timeout = Duration::from_millis(settings.timeout_ms); + let client = Client::builder() + .connect_timeout(timeout) + .timeout(timeout) + .redirect(Policy::none()) + .user_agent(concat!("pgdog-google-auth/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(ConfigError::Client)?; + + let server_password = if settings.provision { + if settings.allowed_domains.is_empty() && settings.allowed_emails.is_empty() { + return Err(ConfigError::Invalid( + "provision = true requires allowed_domains or allowed_emails".into(), + )); + } + + let server_user = settings + .server_user + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ConfigError::Invalid("provision = true requires server_user".into()) + })?; + settings.server_user = Some(server_user.to_owned()); + + let variable = settings + .server_password_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ConfigError::Invalid("provision = true requires server_password_env".into()) + })? + .to_owned(); + settings.server_password_env = Some(variable.clone()); + + let password = env::var(&variable).map_err(|_| ConfigError::MissingSecret { + variable: variable.clone(), + })?; + if password.is_empty() { + return Err(ConfigError::MissingSecret { variable }); + } + Some(password) + } else { + None + }; + + Ok(Self { + settings, + endpoint, + client, + server_password, + }) + } +} + +impl Settings { + /// Whether the startup user belongs to the Google-authenticated namespace. + /// + /// A full email startup user can be routed before token introspection when + /// the token-derived email must match it. Other identity modes need the + /// token response before ownership can be determined, so the plugin claims + /// them and preserves fail-closed behavior. + pub(crate) fn claims_user(&self, user: &str) -> bool { + if !self.require_user_match + || self.username_claim != UsernameClaim::Email + || self.strip_email_domain + { + return true; + } + + let normalized = user.to_ascii_lowercase(); + let Some((local, domain)) = normalized.rsplit_once('@') else { + return false; + }; + if local.is_empty() || domain.is_empty() { + return false; + } + + if self.allowed_domains.is_empty() && self.allowed_emails.is_empty() { + return true; + } + + self.allowed_emails + .iter() + .any(|allowed| allowed == &normalized) + || self.allowed_domains.iter().any(|allowed| allowed == domain) + } +} + +fn normalize_list( + values: &mut [String], + field: &'static str, + lowercase: bool, +) -> Result<(), ConfigError> { + for value in values { + *value = value.trim().to_owned(); + if value.is_empty() { + return Err(ConfigError::Invalid(format!( + "{field} cannot contain empty values" + ))); + } + if lowercase { + value.make_ascii_lowercase(); + } + } + Ok(()) +} + +fn validate_endpoint(value: &str) -> Result { + let url = Url::parse(value).map_err(ConfigError::Url)?; + + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::Invalid( + "tokeninfo_url cannot contain credentials, a query, or a fragment".into(), + )); + } + + match url.scheme() { + "https" => Ok(url), + "http" if loopback(&url) => Ok(url), + _ => Err(ConfigError::Invalid( + "tokeninfo_url must use HTTPS; HTTP is allowed only for loopback tests".into(), + )), + } +} + +fn loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + +#[derive(Debug, Error)] +pub(crate) enum ConfigError { + #[error("failed to read Google auth config {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse Google auth config {path}: {source}")] + Parse { + path: String, + #[source] + source: toml::de::Error, + }, + #[error("invalid tokeninfo_url: {0}")] + Url(#[source] url::ParseError), + #[error("failed to create Google tokeninfo client: {0}")] + Client(#[source] reqwest::Error), + #[error("missing backend password in environment variable {variable}")] + MissingSecret { variable: String }, + #[error("invalid Google auth configuration: {0}")] + Invalid(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_secure() { + let settings = Settings::default(); + + assert!(settings.require_tls); + assert!(settings.require_user_match); + assert!(settings.require_verified_email); + assert!(settings.impersonate); + assert!(!settings.provision); + assert_eq!(settings.username_claim, UsernameClaim::Email); + } + + /// Settings that load: `allowed_audiences` is mandatory, so every test + /// that expects `from_settings` to succeed has to set it. + fn loadable() -> Settings { + Settings { + allowed_audiences: vec!["gcloud-client".into()], + ..Default::default() + } + } + + #[test] + fn requires_an_audience_allowlist() { + let error = RuntimeConfig::from_settings(Settings::default()) + .err() + .expect("an empty allowed_audiences must not load"); + assert!(error.to_string().contains("allowed_audiences is required")); + } + + #[test] + fn rejects_non_loopback_http_endpoint() { + let settings = Settings { + tokeninfo_url: "http://example.com/tokeninfo".into(), + ..Default::default() + }; + + assert!(RuntimeConfig::from_settings(settings).is_err()); + } + + #[test] + fn accepts_loopback_http_endpoint_for_tests() { + for tokeninfo_url in [ + "http://127.0.0.1:12345/tokeninfo", + "http://[::1]:12345/tokeninfo", + "http://localhost:12345/tokeninfo", + ] { + let settings = Settings { + tokeninfo_url: tokeninfo_url.into(), + ..loadable() + }; + + assert!( + RuntimeConfig::from_settings(settings).is_ok(), + "{tokeninfo_url}" + ); + } + } + + #[test] + fn rejects_auto_provisioning_without_principal_allowlist() { + let settings = Settings { + provision: true, + server_user: Some("pgdog_service".into()), + server_password_env: Some("PGDOG_TEST_PASSWORD".into()), + ..loadable() + }; + + let error = RuntimeConfig::from_settings(settings) + .err() + .expect("configuration should fail"); + assert!(error.to_string().contains("allowed_domains")); + } + + #[test] + fn parses_config_file() { + let directory = tempfile::tempdir().expect("create temp directory"); + let path = directory.path().join("google-auth.toml"); + fs::write( + &path, + r#" +require_tls = true +username_claim = "user_id" +allowed_audiences = ["gcloud-client"] +allowed_domains = ["Example.COM"] +required_scopes = ["scope-a"] +"#, + ) + .expect("write config"); + + let runtime = RuntimeConfig::load(Some(&path)).expect("load config"); + assert_eq!(runtime.settings.username_claim, UsernameClaim::UserId); + assert_eq!(runtime.settings.allowed_audiences, ["gcloud-client"]); + assert_eq!(runtime.settings.allowed_domains, ["example.com"]); + assert_eq!(runtime.settings.required_scopes, ["scope-a"]); + } + + #[test] + fn routes_only_matching_full_email_users_when_possible() { + let settings = Settings { + allowed_domains: vec!["example.com".into()], + allowed_emails: vec!["specific@other.test".into()], + ..Default::default() + }; + + assert!(settings.claims_user("alice@example.com")); + assert!(settings.claims_user("specific@other.test")); + assert!(!settings.claims_user("postgres")); + assert!(!settings.claims_user("alice@other.test")); + + let derived_identity = Settings { + require_user_match: false, + ..settings.clone() + }; + assert!(derived_identity.claims_user("postgres")); + + let stripped_email = Settings { + strip_email_domain: true, + ..settings.clone() + }; + assert!(stripped_email.claims_user("alice")); + + let user_id = Settings { + username_claim: UsernameClaim::UserId, + ..settings + }; + assert!(user_id.claims_user("1234567890")); + } +} diff --git a/plugins/pgdog-google-auth/src/lib.rs b/plugins/pgdog-google-auth/src/lib.rs new file mode 100644 index 000000000..676026e29 --- /dev/null +++ b/plugins/pgdog-google-auth/src/lib.rs @@ -0,0 +1,56 @@ +//! Google OAuth 2.0 access-token authentication for PgDog. + +mod config; +mod token_info; + +use std::{path::Path, sync::Arc}; + +use config::RuntimeConfig; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use pgdog_plugin::{AuthContext, AuthDecision, Config as PluginConfig, PdStr, Plugin, plugin}; +use tracing::{error, info}; + +plugin!(GoogleAuthPlugin); + +struct GoogleAuthPlugin; + +static RUNTIME: Lazy>>> = Lazy::new(|| RwLock::new(None)); + +impl Plugin for GoogleAuthPlugin { + extern "C-unwind" fn version() -> PdStr<'static> { + env!("CARGO_PKG_VERSION").into() + } + + extern "C-unwind" fn config(config: PluginConfig<'_>) -> bool { + let path = (!config.plugin_config.is_empty()).then(|| Path::new(&*config.plugin_config)); + + match RuntimeConfig::load(path) { + Ok(runtime) => { + info!("[pgdog_google_auth] configured Google access-token authentication"); + *RUNTIME.write() = Some(Arc::new(runtime)); + true + } + Err(err) => { + error!("[pgdog_google_auth] configuration failed: {err}"); + *RUNTIME.write() = None; + false + } + } + } + + fn authenticate(context: AuthContext<'_>) -> AuthDecision { + let Some(runtime) = RUNTIME.read().clone() else { + return AuthDecision::Deny("Google authentication plugin is not configured".into()); + }; + + if !runtime.settings.claims_user(&context.user) { + return AuthDecision::Skip; + } + + match token_info::authenticate(&runtime, &context.user, &context.credential, context.tls) { + Ok(grant) => AuthDecision::Allow(grant), + Err(err) => AuthDecision::Deny(err.to_string()), + } + } +} diff --git a/plugins/pgdog-google-auth/src/token_info.rs b/plugins/pgdog-google-auth/src/token_info.rs new file mode 100644 index 000000000..7430dfe4a --- /dev/null +++ b/plugins/pgdog-google-auth/src/token_info.rs @@ -0,0 +1,667 @@ +use std::{ + collections::HashSet, + io::{Read, Take}, +}; + +use pgdog_plugin::AuthGrant; +use serde::Deserialize; +use thiserror::Error; + +use crate::config::{RuntimeConfig, Settings, UsernameClaim}; + +const MAX_TOKEN_LENGTH: usize = 16 * 1024; +const MAX_RESPONSE_LENGTH: u64 = 64 * 1024; +const MAX_POSTGRES_USERNAME_LENGTH: usize = 63; + +#[derive(Debug, Deserialize)] +struct TokenInfo { + #[serde(alias = "aud")] + audience: Option, + #[serde(alias = "azp")] + issued_to: Option, + #[serde(alias = "sub")] + user_id: Option, + scope: Option, + #[serde(default, deserialize_with = "deserialize_str_i64")] + expires_in: Option, + email: Option, + #[serde( + default, + alias = "email_verified", + deserialize_with = "deserialize_str_bool" + )] + verified_email: Option, +} + +/// Google's tokeninfo endpoint stringifies booleans (`"email_verified": +/// "true"`), while other identity endpoints use real JSON booleans; accept +/// both. `deserialize_with` disables serde's implicit missing-field handling +/// for `Option`, hence the explicit `default` on the field above. +fn deserialize_str_bool<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + struct StrBool; + + impl serde::de::Visitor<'_> for StrBool { + type Value = bool; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(r#"a boolean or "true"/"false""#) + } + + fn visit_bool(self, value: bool) -> Result + where + E: serde::de::Error, + { + Ok(value) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(E::unknown_variant(value, &["true", "false"])), + } + } + } + + deserializer.deserialize_any(StrBool).map(Some) +} + +/// Seconds are a JSON number on some endpoints and a decimal string on +/// others (Google's tokeninfo sends `"expires_in": "3599"`); accept both, and +/// reject anything else rather than panicking on a bad parse. As with +/// [`deserialize_str_bool`], `deserialize_with` disables serde's implicit +/// handling of a missing `Option` field, hence the `default` on the field. +fn deserialize_str_i64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + struct StrI64; + + impl serde::de::Visitor<'_> for StrI64 { + type Value = i64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an integer or a decimal string") + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + Ok(value) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + i64::try_from(value).map_err(|_| E::custom("value is out of range")) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + value + .trim() + .parse() + .map_err(|_| E::custom("expected a decimal string")) + } + } + + deserializer.deserialize_any(StrI64).map(Some) +} + +pub(crate) fn authenticate( + runtime: &RuntimeConfig, + startup_user: &str, + credential: &str, + tls: bool, +) -> Result { + if runtime.settings.require_tls && !tls { + return Err(AuthenticationError::TlsRequired); + } + if credential.is_empty() || credential.len() > MAX_TOKEN_LENGTH { + return Err(AuthenticationError::InvalidCredential); + } + + let token_info = fetch(runtime, credential)?; + validate(&runtime.settings, startup_user, token_info).map(|username| AuthGrant { + derived_user: Some(username.clone()), + server_role: runtime.settings.impersonate.then_some(username), + server_user: runtime.settings.server_user.clone(), + server_password: runtime.server_password.clone(), + read_only: runtime.settings.read_only, + provision: runtime.settings.provision, + }) +} + +fn fetch(runtime: &RuntimeConfig, credential: &str) -> Result { + // POST the token in the request body. A query string ends up in access + // logs, proxy logs and browser-style history along the way; Google accepts + // both spellings. + let response = runtime + .client + .post(runtime.endpoint.clone()) + .form(&[("access_token", credential)]) + .send() + .map_err(request_error)?; + + if !response.status().is_success() { + return Err(AuthenticationError::Rejected); + } + + let mut body = Vec::new(); + let mut limited: Take<_> = response.take(MAX_RESPONSE_LENGTH + 1); + limited + .read_to_end(&mut body) + .map_err(|error| AuthenticationError::Request(error.to_string()))?; + if body.len() as u64 > MAX_RESPONSE_LENGTH { + return Err(AuthenticationError::ResponseTooLarge); + } + + serde_json::from_slice(&body).map_err(AuthenticationError::InvalidResponse) +} + +fn request_error(error: reqwest::Error) -> AuthenticationError { + AuthenticationError::Request(error.without_url().to_string()) +} + +fn validate( + settings: &Settings, + startup_user: &str, + token_info: TokenInfo, +) -> Result { + // A token that reports no remaining lifetime, or no lifetime at all, is + // not accepted. + if token_info.expires_in.unwrap_or_default() <= 0 { + return Err(AuthenticationError::Expired); + } + + validate_audience(settings, &token_info)?; + validate_scopes(settings, &token_info)?; + + let email = token_info + .email + .as_deref() + .map(str::trim) + .filter(|email| !email.is_empty()) + .map(str::to_ascii_lowercase); + let email_required = settings.username_claim == UsernameClaim::Email + || !settings.allowed_domains.is_empty() + || !settings.allowed_emails.is_empty(); + + if email_required && email.is_none() { + return Err(AuthenticationError::MissingEmail); + } + if email_required && settings.require_verified_email && token_info.verified_email != Some(true) + { + return Err(AuthenticationError::UnverifiedEmail); + } + if email_required { + validate_email(email.as_deref().ok_or(AuthenticationError::MissingEmail)?)?; + } + + if !settings.allowed_domains.is_empty() || !settings.allowed_emails.is_empty() { + let email = email.as_deref().ok_or(AuthenticationError::MissingEmail)?; + let (_, domain) = validate_email(email)?; + let allowed = settings + .allowed_emails + .iter() + .any(|allowed| allowed == email) + || settings + .allowed_domains + .iter() + .any(|allowed| allowed == domain); + if !allowed { + return Err(AuthenticationError::PrincipalNotAllowed); + } + } + + let username = match settings.username_claim { + UsernameClaim::Email => { + let email = email.ok_or(AuthenticationError::MissingEmail)?; + if settings.strip_email_domain { + validate_email(&email)?.0.to_owned() + } else { + email + } + } + UsernameClaim::UserId => token_info + .user_id + .as_deref() + .map(str::trim) + .filter(|user_id| !user_id.is_empty()) + .map(str::to_owned) + .ok_or(AuthenticationError::MissingUserId)?, + }; + + if username.len() > MAX_POSTGRES_USERNAME_LENGTH { + return Err(AuthenticationError::UsernameTooLong); + } + // The name becomes a PostgreSQL role, a `server_role` startup parameter + // and a PgDog config entry. Only reachable from a hostile `tokeninfo_url`, + // since Google does not mint identities like this, but cheap to refuse. + if username + .chars() + .any(|c| c.is_control() || c.is_whitespace()) + { + return Err(AuthenticationError::InvalidUsername); + } + // `claims_user` decides whether to handle this login by lower-casing the + // startup user, so compare the same way: otherwise `Alice@Example.com` is + // claimed and then denied instead of being left to the next plugin. + if settings.require_user_match && !startup_user.eq_ignore_ascii_case(&username) { + return Err(AuthenticationError::UserMismatch); + } + + Ok(username) +} + +fn validate_email(email: &str) -> Result<(&str, &str), AuthenticationError> { + let (local, domain) = email + .rsplit_once('@') + .ok_or(AuthenticationError::InvalidEmail)?; + + if local.is_empty() || domain.is_empty() { + return Err(AuthenticationError::InvalidEmail); + } + + Ok((local, domain)) +} + +fn validate_audience( + settings: &Settings, + token_info: &TokenInfo, +) -> Result<(), AuthenticationError> { + // `RuntimeConfig::from_settings` refuses to build without an audience + // allow-list; if an empty one ever reaches here, accept nothing. + let accepted = !settings.allowed_audiences.is_empty() + && token_info + .audience + .iter() + .chain(token_info.issued_to.iter()) + .any(|audience| { + settings + .allowed_audiences + .iter() + .any(|allowed| allowed == audience) + }); + + accepted + .then_some(()) + .ok_or(AuthenticationError::AudienceNotAllowed) +} + +fn validate_scopes(settings: &Settings, token_info: &TokenInfo) -> Result<(), AuthenticationError> { + if settings.required_scopes.is_empty() { + return Ok(()); + } + + let scopes: HashSet<_> = token_info + .scope + .as_deref() + .unwrap_or_default() + .split_whitespace() + .collect(); + + settings + .required_scopes + .iter() + .all(|scope| scopes.contains(scope.as_str())) + .then_some(()) + .ok_or(AuthenticationError::MissingScope) +} + +#[derive(Debug, Error)] +pub(crate) enum AuthenticationError { + #[error("Google access tokens require a TLS client connection")] + TlsRequired, + #[error("invalid Google access token")] + InvalidCredential, + #[error("Google tokeninfo request failed: {0}")] + Request(String), + #[error("Google rejected the access token")] + Rejected, + #[error("Google tokeninfo response exceeded the size limit")] + ResponseTooLarge, + #[error("Google tokeninfo returned an invalid response: {0}")] + InvalidResponse(#[source] serde_json::Error), + #[error("Google access token is expired")] + Expired, + #[error("Google access token has no email identity")] + MissingEmail, + #[error("Google access token email is not verified")] + UnverifiedEmail, + #[error("Google access token contains an invalid email identity")] + InvalidEmail, + #[error("Google account is not allowed")] + PrincipalNotAllowed, + #[error("Google access token audience is not allowed")] + AudienceNotAllowed, + #[error("Google access token is missing a required scope")] + MissingScope, + #[error("Google access token has no user_id identity")] + MissingUserId, + #[error("Google identity exceeds PostgreSQL's 63-byte user-name limit")] + UsernameTooLong, + #[error("Google identity contains characters PostgreSQL cannot use in a user name")] + InvalidUsername, + #[error("PostgreSQL startup user does not match the Google identity")] + UserMismatch, +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + use super::*; + use crate::config::Settings; + + fn token_info() -> TokenInfo { + TokenInfo { + audience: Some("gcloud-client".into()), + issued_to: None, + user_id: Some("1234567890".into()), + scope: Some("scope-a scope-b".into()), + expires_in: Some(3600), + email: Some("alice@example.com".into()), + verified_email: Some(true), + } + } + + fn settings() -> Settings { + Settings { + require_tls: false, + allowed_audiences: vec!["gcloud-client".into()], + allowed_domains: vec!["example.com".into()], + required_scopes: vec!["scope-a".into()], + ..Default::default() + } + } + + fn mock_server( + status: &'static str, + body: &'static str, + ) -> (String, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + let address = listener.local_addr().expect("mock server address"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0u8; 4096]; + let size = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..size]).into_owned(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + request + }); + + (format!("http://{address}/tokeninfo"), handle) + } + + #[test] + fn accepts_verified_allowed_identity() { + let username = validate(&settings(), "alice@example.com", token_info()) + .expect("token should validate"); + + assert_eq!(username, "alice@example.com"); + } + + #[test] + fn can_use_user_id_as_postgres_identity() { + let settings = Settings { + username_claim: UsernameClaim::UserId, + require_user_match: false, + require_verified_email: false, + allowed_audiences: vec!["gcloud-client".into()], + ..Default::default() + }; + + assert_eq!( + validate(&settings, "ignored", token_info()).expect("token should validate"), + "1234567890" + ); + } + + #[test] + fn rejects_expired_token() { + let mut token = token_info(); + token.expires_in = Some(0); + + assert!(matches!( + validate(&settings(), "alice@example.com", token), + Err(AuthenticationError::Expired) + )); + } + + #[test] + fn rejects_unverified_email() { + let mut token = token_info(); + token.verified_email = Some(false); + + assert!(matches!( + validate(&settings(), "alice@example.com", token), + Err(AuthenticationError::UnverifiedEmail) + )); + } + + #[test] + fn rejects_disallowed_audience_domain_and_scope() { + let mut audience = settings(); + audience.allowed_audiences = vec!["different-client".into()]; + assert!(matches!( + validate(&audience, "alice@example.com", token_info()), + Err(AuthenticationError::AudienceNotAllowed) + )); + + let mut domain = settings(); + domain.allowed_domains = vec!["other.example".into()]; + assert!(matches!( + validate(&domain, "alice@example.com", token_info()), + Err(AuthenticationError::PrincipalNotAllowed) + )); + + let mut scope = settings(); + scope.required_scopes = vec!["scope-c".into()]; + assert!(matches!( + validate(&scope, "alice@example.com", token_info()), + Err(AuthenticationError::MissingScope) + )); + } + + #[test] + fn rejects_startup_user_mismatch() { + assert!(matches!( + validate(&settings(), "bob@example.com", token_info()), + Err(AuthenticationError::UserMismatch) + )); + } + + #[test] + fn rejects_malformed_email_identity() { + for email in ["alice", "@example.com", "alice@"] { + let mut token = token_info(); + token.email = Some(email.into()); + + assert!(matches!( + validate(&settings(), email, token), + Err(AuthenticationError::InvalidEmail) + )); + } + } + + #[test] + fn accepts_oidc_tokeninfo_field_names() { + let token_info: TokenInfo = serde_json::from_str( + r#"{ + "aud": "gcloud-client", + "azp": "authorized-party", + "sub": "1234567890", + "scope": "scope-a", + "expires_in": "3600", + "email": "alice@example.com", + "email_verified": "true" + }"#, + ) + .expect("parse tokeninfo response"); + + assert_eq!( + validate(&settings(), "alice@example.com", token_info).expect("token should validate"), + "alice@example.com" + ); + } + + #[test] + fn accepts_boolean_and_missing_email_verified() { + // Other Google identity endpoints send a real JSON boolean. + let token_info: TokenInfo = serde_json::from_str( + r#"{"expires_in": "3600", "email": "alice@example.com", "email_verified": true}"#, + ) + .expect("parse boolean email_verified"); + assert_eq!(token_info.verified_email, Some(true)); + + // Tokens without the email scope omit the field entirely. + let token_info: TokenInfo = + serde_json::from_str(r#"{"expires_in": "3600"}"#).expect("parse missing field"); + assert_eq!(token_info.verified_email, None); + + assert!( + serde_json::from_str::(r#"{"expires_in": "3600", "email_verified": "yes"}"#) + .is_err() + ); + } + + #[test] + fn accepts_numeric_and_string_expires_in() { + // Google's tokeninfo stringifies it; other endpoints send a number. + let token_info: TokenInfo = + serde_json::from_str(r#"{"expires_in": 3599, "email": "alice@example.com"}"#) + .expect("parse numeric expires_in"); + assert_eq!(token_info.expires_in, Some(3599)); + + let token_info: TokenInfo = + serde_json::from_str(r#"{"expires_in": "3599", "email": "alice@example.com"}"#) + .expect("parse string expires_in"); + assert_eq!(token_info.expires_in, Some(3599)); + + // A value that is neither is a response error, not a panic. + assert!( + serde_json::from_str::(r#"{"expires_in": "soon"}"#).is_err(), + "a non-numeric expires_in must be rejected" + ); + + // Missing entirely: treated as no lifetime left. + let token_info: TokenInfo = + serde_json::from_str(r#"{"email": "alice@example.com"}"#).expect("parse without it"); + assert_eq!(token_info.expires_in, None); + assert!(matches!( + validate(&settings(), "alice@example.com", token_info), + Err(AuthenticationError::Expired) + )); + } + + #[test] + fn matches_the_startup_user_case_insensitively() { + // `Settings::claims_user` lower-cases before deciding whether to + // handle the login, so validation must not then reject the same user. + assert!(settings().claims_user("Alice@Example.com")); + assert_eq!( + validate(&settings(), "Alice@Example.com", token_info()).expect("should validate"), + "alice@example.com" + ); + } + + #[test] + fn rejects_identities_postgres_cannot_use() { + for email in ["ali ce@example.com", "alice\u{7}@example.com"] { + let mut token = token_info(); + token.email = Some(email.into()); + + let mut settings = settings(); + settings.require_user_match = false; + settings.allowed_domains = Vec::new(); + settings.allowed_emails = Vec::new(); + + assert!( + matches!( + validate(&settings, "ignored", token), + Err(AuthenticationError::InvalidUsername) + ), + "{email}" + ); + } + } + + #[test] + fn calls_tokeninfo_without_leaking_token_in_errors() { + let body = r#"{ + "azp": "42789329387.apps.googleusercontent.com", + "aud": "42789329387.apps.googleusercontent.com", + "sub": "427893293874278932938", + "scope": "email https://www.googleapis.com/auth/accounts.reauth https://www.googleapis.com/auth/appengine.admin https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/compute https://www.googleapis.com/auth/sqlservice.login https://www.googleapis.com/auth/userinfo.email openid", + "exp": "1787664074", + "expires_in": "2865", + "email": "marco.palmisano@examplecompany.com", + "email_verified": "true", + "access_type": "offline" + }"#; + let (url, request) = mock_server("200 OK", body); + let runtime = RuntimeConfig::from_settings(Settings { + tokeninfo_url: url, + require_tls: false, + require_user_match: false, + allowed_audiences: vec!["42789329387.apps.googleusercontent.com".into()], + ..Default::default() + }) + .expect("create runtime"); + let token = "ya29.a+b/c?"; + + let grant = authenticate(&runtime, "ignored", token, false).expect("authenticate"); + assert_eq!( + grant.derived_user.as_deref(), + Some("marco.palmisano@examplecompany.com") + ); + + let request = request.join().expect("join mock server"); + assert!(request.starts_with("POST /tokeninfo")); + // Nothing of the token in the request line, only in the form body. + let (head, body) = request.split_once("\r\n\r\n").expect("request has a body"); + assert!(!head.contains("access_token")); + assert!(body.starts_with("access_token=")); + assert!(!request.contains(token)); + } + + #[test] + fn rejects_non_success_response() { + let (url, request) = mock_server("400 Bad Request", r#"{"error":"invalid_token"}"#); + let runtime = RuntimeConfig::from_settings(Settings { + tokeninfo_url: url, + require_tls: false, + require_user_match: false, + allowed_audiences: vec!["42789329387.apps.googleusercontent.com".into()], + ..Default::default() + }) + .expect("create runtime"); + + assert!(matches!( + authenticate(&runtime, "ignored", "bad-token", false), + Err(AuthenticationError::Rejected) + )); + request.join().expect("join mock server"); + } +}