Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/workflows/plugin-ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: plugin-ci

on:
push:
branches:
Expand All @@ -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
5 changes: 5 additions & 0 deletions .schema/pgdog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand Down
7 changes: 7 additions & 0 deletions .schema/users.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<https://docs.pgdog.dev/configuration/users.toml/users/#server_user>",
"type": [
Expand Down
21 changes: 20 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ 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/*",
]

[workspace.package]
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" }
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions example.pgdog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
18 changes: 18 additions & 0 deletions example.users.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 3 additions & 1 deletion integration/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
169 changes: 169 additions & 0 deletions integration/plugins/auth/auth_spec.rb
Original file line number Diff line number Diff line change
@@ -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-<user> 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
19 changes: 19 additions & 0 deletions integration/plugins/auth/pgdog.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading