Skip to content
Open
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: 13 additions & 0 deletions .schema/pgdog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"$ref": "#/$defs/General",
"default": {
"auth_type": "scram",
"background_workers": 4,
"ban_replica_lag": 9223372036854775807,
"ban_replica_lag_bytes": 9223372036854775807,
"ban_timeout": 300000,
Expand Down Expand Up @@ -312,6 +313,11 @@
"description": "Plaintext password.",
"type": "string",
"const": "plain"
},
{
"description": "Delegate client authentication to a loaded plugin exposing the `pgdog_authenticate` hook.",
"type": "string",
"const": "plugin"
}
]
},
Expand Down Expand Up @@ -595,6 +601,13 @@
"$ref": "#/$defs/AuthType",
"default": "scram"
},
"background_workers": {
"description": "Maximum number of authentication-plugin calls allowed to run concurrently.\nAuthentication plugins run on a blocking thread pool (auth happens once per\nconnection and may block on I/O); this bounds how many run at the same time.\n\n**Note:** Only relevant when `auth_type = \"plugin\"`.\n\n_Default:_ `4`\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#background_workers",
"type": "integer",
"format": "uint",
"default": 4,
"minimum": 0
},
"ban_replica_lag": {
"description": "Ban a replica from serving read queries if its replication lag (in milliseconds) exceeds this threshold.\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#ban_replica_lag",
"type": "integer",
Expand Down
7 changes: 7 additions & 0 deletions .schema/users.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@
"null"
]
},
"server_role": {
"description": "PostgreSQL role this user's backend connections assume via the `role` startup\nparameter, used for impersonation with `auth_type = \"plugin\"`.\n\n**Note:** The user config no longer supplies the Postgres password when a plugin\nderives the login, so a working backend credential must be provided via\n`server_password` or a non-default `server_auth`. `SET ROLE`, `RESET ROLE`, and\n`SET SESSION AUTHORIZATION` are rejected on pools that set this.",
"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\nhttps://docs.pgdog.dev/configuration/users.toml/users/#server_user",
"type": [
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 = "457b7c9" }
Expand Down
13 changes: 13 additions & 0 deletions example.pgdog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ port = 6432
# Default: 2
# Recommended: 2 per CPU
workers = 2
# Maximum number of authentication-plugin calls allowed to run concurrently.
# Auth plugins run on a blocking thread pool; this caps how many run at once.
# Only relevant when auth_type = "plugin".
#
# Default: 4
background_workers = 4
# Maximum number of Postgres connections per user/database connection pool.
#
# Default: 10
Expand Down Expand Up @@ -265,6 +271,13 @@ mirror_queue = 128
# - scram
# - md5
# - trust
# - plain
# - plugin: delegate client authentication to a loaded plugin exposing the
# `pgdog_authenticate` hook (see the [[plugins]] section). When set, every
# plugin returning Skip results in a denied login (there is no password
# fallback), so a capable plugin must be loaded. Use `background_workers`
# to cap concurrent plugin calls.
# auth_type = "plugin"
auth_type = "scram"
# Disable cross-shard queries.
#
Expand Down
16 changes: 16 additions & 0 deletions example.users.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,19 @@ password = "pgdog"
# server_user = "pgdog_service"
# server_auth = "vault_static"
# server_vault_path = "database/static-creds/pgdog-service"

# Example: role impersonation with plugin authentication (`auth_type = "plugin"`
# in pgdog.toml). Backend connections for this user assume `server_role` via the
# `role` startup parameter, and `SET ROLE` / `RESET ROLE` / `SET SESSION
# AUTHORIZATION` are rejected on the resulting pools.
#
# NOTE: the client no longer supplies the Postgres password (the plugin derives
# the login), so a working backend credential MUST be provided here via
# `server_password` (or a non-default `server_auth`). Without one, PgDog cannot
# open server connections and warns at config load time.
# [[users]]
# name = "analytics"
# database = "pgdog"
# 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
136 changes: 136 additions & 0 deletions integration/plugins/auth/auth_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 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
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 'denies an unknown credential when every plugin skips' 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 '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 '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"` routes every non-admin login through the loaded
# authentication plugins. `test_plugin_auth` (see
# test-plugins/test-plugin-auth) makes the decisions the specs assert on.
#
[general]
auth_type = "plugin"
# Deny reasons are warn!-logged regardless, but log every connection attempt so
# the spec can also observe the auth-error line.
log_connections = true

[[plugins]]
name = "test_plugin_auth"

[[databases]]
name = "pgdog"
host = "127.0.0.1"
15 changes: 15 additions & 0 deletions integration/plugins/auth/setup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- 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;

-- Target table for the read-only pool INSERT rejection test.
CREATE TABLE IF NOT EXISTS auth_test (id BIGINT);
25 changes: 25 additions & 0 deletions integration/plugins/auth/users.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#
# Users for the authentication-plugin suite.
#
# With `auth_type = "plugin"` the client no longer supplies the Postgres
# password (the plugin authenticates the login), so these entries only define
# the backend pool: `server_user`/`server_password` are the credentials PgDog
# uses to connect to PostgreSQL.
#
# 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"
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
17 changes: 17 additions & 0 deletions integration/plugins/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,3 +44,16 @@ wait_for_pgdog
bash ${SCRIPT_DIR}/dev.sh

stop_pgdog

# Phase 2: authentication plugin (auth_type = "plugin").
# Postgres-side prerequisites (impersonated role, 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
16 changes: 16 additions & 0 deletions integration/plugins/test-plugins/test-plugin-auth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "test-plugin-auth"
version = "0.1.0"
edition = "2024"

[workspace]

[lib]
crate-type = ["cdylib"]

[dependencies]
pgdog-plugin = { path = "../../../../pgdog-plugin", default-features = false }

[features]
default = ["pgdog-plugin/pg_query"]
new_parser = ["pgdog-plugin/new_parser"]
Loading