mirror of
https://github.com/postgresml/pgcat.git
synced 2026-03-23 01:16:30 +00:00
Auth passthrough (auth_query) (#266)
* Add a new exec_simple_query method This adds a new `exec_simple_query` method so we can make 'out of band' queries to servers that don't interfere with pools at all. In order to reuse startup code for making these simple queries, we need to set the stats (`Reporter`) optional, so using these simple queries wont interfere with stats. * Add auth passthough (auth_query) Adds a feature that allows setting auth passthrough for md5 auth. It adds 3 new (general and pool) config parameters: - `auth_query`: An string containing a query that will be executed on boot to obtain the hash of a given user. This query have to use a placeholder `$1`, so pgcat can replace it with the user its trying to fetch the hash from. - `auth_query_user`: The user to use for connecting to the server and executing the auth_query. - `auth_query_password`: The password to use for connecting to the server and executing the auth_query. The configuration can be done either on the general config (so pools share them) or in a per-pool basis. The behavior is, at boot time, when validating server connections, a hash is fetched per server and stored in the pool. When new server connections are created, and no cleartext password is specified, the obtained hash is used for creating them, if the hash could not be obtained for whatever reason, it retries it. When client authentication is tried, it uses cleartext passwords if specified, it not, it checks whether we have query_auth set up, if so, it tries to use the obtained hash for making client auth. If there is no hash (we could not obtain one when validating the connection), a new fetch is tried. Once we have a hash, we authenticate using it against whathever the client has sent us, if there is a failure we refetch the hash and retry auth (so password changes can be done). The idea with this 'retrial' mechanism is to make it fault tolerant, so if for whatever reason hash could not be obtained during connection validation, or the password has change, we can still connect later. * Add documentation for Auth passthrough
This commit is contained in:
@@ -36,6 +36,15 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 --auth=scram-sha-256
|
||||
command: ["postgres", "-p", "9432", "-c", "shared_preload_libraries=pg_stat_statements", "-c", "pg_stat_statements.track=all", "-c", "pg_stat_statements.max=100000"]
|
||||
pg5:
|
||||
image: postgres:14
|
||||
network_mode: "service:main"
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_INITDB_ARGS: --auth-local=md5 --auth-host=md5 --auth=md5
|
||||
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements", "-c", "pg_stat_statements.track=all", "-p", "10432"]
|
||||
main:
|
||||
build: .
|
||||
command: ["bash", "/app/tests/docker/run.sh"]
|
||||
|
||||
215
tests/ruby/auth_query_spec.rb
Normal file
215
tests/ruby/auth_query_spec.rb
Normal file
@@ -0,0 +1,215 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'spec_helper'
|
||||
require_relative 'helpers/auth_query_helper'
|
||||
|
||||
describe "Auth Query" do
|
||||
let(:configured_instances) {[5432, 10432]}
|
||||
let(:config_user) { { 'username' => 'sharding_user', 'password' => 'sharding_user' } }
|
||||
let(:pg_user) { { 'username' => 'sharding_user', 'password' => 'sharding_user' } }
|
||||
let(:processes) { Helpers::AuthQuery.single_shard_auth_query(pool_name: "sharded_db", pg_user: pg_user, config_user: config_user, extra_conf: config, wait_until_ready: wait_until_ready ) }
|
||||
let(:config) { {} }
|
||||
let(:wait_until_ready) { true }
|
||||
|
||||
after do
|
||||
unless @failing_process
|
||||
processes.all_databases.map(&:reset)
|
||||
processes.pgcat.shutdown
|
||||
end
|
||||
@failing_process = false
|
||||
end
|
||||
|
||||
context "when auth_query is not configured" do
|
||||
context 'and cleartext passwords are set' do
|
||||
it "uses local passwords" do
|
||||
conn = PG.connect(processes.pgcat.connection_string("sharded_db", config_user['username'], config_user['password']))
|
||||
|
||||
expect(conn.async_exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'and cleartext passwords are not set' do
|
||||
let(:config_user) { { 'username' => 'sharding_user' } }
|
||||
|
||||
it "does not start because it is not possible to authenticate" do
|
||||
@failing_process = true
|
||||
expect { processes.pgcat }.to raise_error(StandardError, /You have to specify a user password for every pool if auth_query is not specified/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when auth_query is configured' do
|
||||
context 'with global configuration' do
|
||||
around(:example) do |example|
|
||||
|
||||
# Set up auth query
|
||||
Helpers::AuthQuery.set_up_auth_query_for_user(
|
||||
user: 'md5_auth_user',
|
||||
password: 'secret'
|
||||
);
|
||||
|
||||
example.run
|
||||
|
||||
# Drop auth query support
|
||||
Helpers::AuthQuery.tear_down_auth_query_for_user(
|
||||
user: 'md5_auth_user',
|
||||
password: 'secret'
|
||||
);
|
||||
end
|
||||
|
||||
context 'with correct global parameters' do
|
||||
let(:config) { { 'general' => { 'auth_query' => "SELECT * FROM public.user_lookup('$1');", 'auth_query_user' => 'md5_auth_user', 'auth_query_password' => 'secret' } } }
|
||||
context 'and with cleartext passwords set' do
|
||||
it 'it uses local passwords' do
|
||||
conn = PG.connect(processes.pgcat.connection_string("sharded_db", pg_user['username'], pg_user['password']))
|
||||
expect(conn.exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'and with cleartext passwords not set' do
|
||||
let(:config_user) { { 'username' => 'sharding_user', 'password' => 'sharding_user' } }
|
||||
|
||||
it 'it uses obtained passwords' do
|
||||
connection_string = processes.pgcat.connection_string("sharded_db", pg_user['username'], pg_user['password'])
|
||||
conn = PG.connect(connection_string)
|
||||
expect(conn.async_exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
|
||||
it 'allows passwords to be changed without closing existing connections' do
|
||||
pgconn = PG.connect(processes.pgcat.connection_string("sharded_db", pg_user['username']))
|
||||
expect(pgconn.exec("SELECT 1 + 2")).not_to be_nil
|
||||
Helpers::AuthQuery.exec_in_instances(query: "ALTER USER #{pg_user['username']} WITH ENCRYPTED PASSWORD 'secret2';")
|
||||
expect(pgconn.exec("SELECT 1 + 4")).not_to be_nil
|
||||
Helpers::AuthQuery.exec_in_instances(query: "ALTER USER #{pg_user['username']} WITH ENCRYPTED PASSWORD '#{pg_user['password']}';")
|
||||
end
|
||||
|
||||
it 'allows passwords to be changed and that new password is needed when reconnecting' do
|
||||
pgconn = PG.connect(processes.pgcat.connection_string("sharded_db", pg_user['username']))
|
||||
expect(pgconn.exec("SELECT 1 + 2")).not_to be_nil
|
||||
Helpers::AuthQuery.exec_in_instances(query: "ALTER USER #{pg_user['username']} WITH ENCRYPTED PASSWORD 'secret2';")
|
||||
newconn = PG.connect(processes.pgcat.connection_string("sharded_db", pg_user['username'], 'secret2'))
|
||||
expect(newconn.exec("SELECT 1 + 2")).not_to be_nil
|
||||
Helpers::AuthQuery.exec_in_instances(query: "ALTER USER #{pg_user['username']} WITH ENCRYPTED PASSWORD '#{pg_user['password']}';")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with wrong parameters' do
|
||||
let(:config) { { 'general' => { 'auth_query' => 'SELECT 1', 'auth_query_user' => 'wrong_user', 'auth_query_password' => 'wrong' } } }
|
||||
|
||||
context 'and with clear text passwords set' do
|
||||
it "it uses local passwords" do
|
||||
conn = PG.connect(processes.pgcat.connection_string("sharded_db", pg_user['username'], pg_user['password']))
|
||||
|
||||
expect(conn.async_exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'and with cleartext passwords not set' do
|
||||
let(:config_user) { { 'username' => 'sharding_user' } }
|
||||
it "it fails to start as it cannot authenticate against servers" do
|
||||
@failing_process = true
|
||||
expect { PG.connect(processes.pgcat.connection_string("sharded_db", pg_user['username'], pg_user['password'])) }.to raise_error(StandardError, /Error trying to obtain password from auth_query/ )
|
||||
end
|
||||
|
||||
context 'and we fix the issue and reload' do
|
||||
let(:wait_until_ready) { false }
|
||||
|
||||
it 'fails in the beginning but starts working after reloading config' do
|
||||
connection_string = processes.pgcat.connection_string("sharded_db", pg_user['username'], pg_user['password'])
|
||||
while !(processes.pgcat.logs =~ /Waiting for clients/) do
|
||||
sleep 0.5
|
||||
end
|
||||
|
||||
expect { PG.connect(connection_string)}.to raise_error(PG::ConnectionBad)
|
||||
expect(processes.pgcat.logs).to match(/Error trying to obtain password from auth_query/)
|
||||
|
||||
current_config = processes.pgcat.current_config
|
||||
config = { 'general' => { 'auth_query' => "SELECT * FROM public.user_lookup('$1');", 'auth_query_user' => 'md5_auth_user', 'auth_query_password' => 'secret' } }
|
||||
processes.pgcat.update_config(current_config.deep_merge(config))
|
||||
processes.pgcat.reload_config
|
||||
|
||||
conn = nil
|
||||
expect { conn = PG.connect(connection_string)}.not_to raise_error
|
||||
expect(conn.async_exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with per pool configuration' do
|
||||
around(:example) do |example|
|
||||
|
||||
# Set up auth query
|
||||
Helpers::AuthQuery.set_up_auth_query_for_user(
|
||||
user: 'md5_auth_user',
|
||||
password: 'secret'
|
||||
);
|
||||
|
||||
Helpers::AuthQuery.set_up_auth_query_for_user(
|
||||
user: 'md5_auth_user1',
|
||||
password: 'secret',
|
||||
database: 'shard1'
|
||||
);
|
||||
|
||||
example.run
|
||||
|
||||
# Tear down auth query
|
||||
Helpers::AuthQuery.tear_down_auth_query_for_user(
|
||||
user: 'md5_auth_user',
|
||||
password: 'secret'
|
||||
);
|
||||
|
||||
Helpers::AuthQuery.tear_down_auth_query_for_user(
|
||||
user: 'md5_auth_user1',
|
||||
password: 'secret',
|
||||
database: 'shard1'
|
||||
);
|
||||
end
|
||||
|
||||
context 'with correct parameters' do
|
||||
let(:processes) { Helpers::AuthQuery.two_pools_auth_query(pool_names: ["sharded_db0", "sharded_db1"], pg_user: pg_user, config_user: config_user, extra_conf: config ) }
|
||||
let(:config) {
|
||||
{ 'pools' =>
|
||||
{
|
||||
'sharded_db0' => {
|
||||
'auth_query' => "SELECT * FROM public.user_lookup('$1');",
|
||||
'auth_query_user' => 'md5_auth_user',
|
||||
'auth_query_password' => 'secret'
|
||||
},
|
||||
'sharded_db1' => {
|
||||
'auth_query' => "SELECT * FROM public.user_lookup('$1');",
|
||||
'auth_query_user' => 'md5_auth_user1',
|
||||
'auth_query_password' => 'secret'
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context 'and with cleartext passwords set' do
|
||||
it 'it uses local passwords' do
|
||||
conn = PG.connect(processes.pgcat.connection_string("sharded_db0", pg_user['username'], pg_user['password']))
|
||||
expect(conn.exec("SELECT 1 + 2")).not_to be_nil
|
||||
conn = PG.connect(processes.pgcat.connection_string("sharded_db1", pg_user['username'], pg_user['password']))
|
||||
expect(conn.exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'and with cleartext passwords not set' do
|
||||
let(:config_user) { { 'username' => 'sharding_user' } }
|
||||
|
||||
it 'it uses obtained passwords' do
|
||||
connection_string = processes.pgcat.connection_string("sharded_db0", pg_user['username'], pg_user['password'])
|
||||
conn = PG.connect(connection_string)
|
||||
expect(conn.async_exec("SELECT 1 + 2")).not_to be_nil
|
||||
connection_string = processes.pgcat.connection_string("sharded_db1", pg_user['username'], pg_user['password'])
|
||||
conn = PG.connect(connection_string)
|
||||
expect(conn.async_exec("SELECT 1 + 2")).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
173
tests/ruby/helpers/auth_query_helper.rb
Normal file
173
tests/ruby/helpers/auth_query_helper.rb
Normal file
@@ -0,0 +1,173 @@
|
||||
module Helpers
|
||||
module AuthQuery
|
||||
def self.single_shard_auth_query(
|
||||
pg_user:,
|
||||
config_user:,
|
||||
pool_name:,
|
||||
extra_conf: {},
|
||||
log_level: 'debug',
|
||||
wait_until_ready: true
|
||||
)
|
||||
|
||||
user = {
|
||||
"pool_size" => 10,
|
||||
"statement_timeout" => 0,
|
||||
}
|
||||
|
||||
pgcat = PgcatProcess.new(log_level)
|
||||
pgcat_cfg = pgcat.current_config.deep_merge(extra_conf)
|
||||
|
||||
primary = PgInstance.new(5432, pg_user["username"], pg_user["password"], "shard0")
|
||||
replica = PgInstance.new(10432, pg_user["username"], pg_user["password"], "shard0")
|
||||
|
||||
# Main proxy configs
|
||||
pgcat_cfg["pools"] = {
|
||||
"#{pool_name}" => {
|
||||
"default_role" => "any",
|
||||
"pool_mode" => "transaction",
|
||||
"load_balancing_mode" => "random",
|
||||
"primary_reads_enabled" => false,
|
||||
"query_parser_enabled" => false,
|
||||
"sharding_function" => "pg_bigint_hash",
|
||||
"shards" => {
|
||||
"0" => {
|
||||
"database" => "shard0",
|
||||
"servers" => [
|
||||
["localhost", primary.port.to_s, "primary"],
|
||||
["localhost", replica.port.to_s, "replica"],
|
||||
]
|
||||
},
|
||||
},
|
||||
"users" => { "0" => user.merge(config_user) }
|
||||
}
|
||||
}
|
||||
pgcat_cfg["general"]["port"] = pgcat.port
|
||||
pgcat.update_config(pgcat_cfg)
|
||||
pgcat.start
|
||||
|
||||
pgcat.wait_until_ready(
|
||||
pgcat.connection_string(
|
||||
"sharded_db",
|
||||
pg_user['username'],
|
||||
pg_user['password']
|
||||
)
|
||||
) if wait_until_ready
|
||||
|
||||
OpenStruct.new.tap do |struct|
|
||||
struct.pgcat = pgcat
|
||||
struct.primary = primary
|
||||
struct.replicas = [replica]
|
||||
struct.all_databases = [primary]
|
||||
end
|
||||
end
|
||||
|
||||
def self.two_pools_auth_query(
|
||||
pg_user:,
|
||||
config_user:,
|
||||
pool_names:,
|
||||
extra_conf: {},
|
||||
log_level: 'debug'
|
||||
)
|
||||
|
||||
user = {
|
||||
"pool_size" => 10,
|
||||
"statement_timeout" => 0,
|
||||
}
|
||||
|
||||
pgcat = PgcatProcess.new(log_level)
|
||||
pgcat_cfg = pgcat.current_config
|
||||
|
||||
primary = PgInstance.new(5432, pg_user["username"], pg_user["password"], "shard0")
|
||||
replica = PgInstance.new(10432, pg_user["username"], pg_user["password"], "shard0")
|
||||
|
||||
pool_template = Proc.new do |database|
|
||||
{
|
||||
"default_role" => "any",
|
||||
"pool_mode" => "transaction",
|
||||
"load_balancing_mode" => "random",
|
||||
"primary_reads_enabled" => false,
|
||||
"query_parser_enabled" => false,
|
||||
"sharding_function" => "pg_bigint_hash",
|
||||
"shards" => {
|
||||
"0" => {
|
||||
"database" => database,
|
||||
"servers" => [
|
||||
["localhost", primary.port.to_s, "primary"],
|
||||
["localhost", replica.port.to_s, "replica"],
|
||||
]
|
||||
},
|
||||
},
|
||||
"users" => { "0" => user.merge(config_user) }
|
||||
}
|
||||
end
|
||||
# Main proxy configs
|
||||
pgcat_cfg["pools"] = {
|
||||
"#{pool_names[0]}" => pool_template.call("shard0"),
|
||||
"#{pool_names[1]}" => pool_template.call("shard1")
|
||||
}
|
||||
|
||||
pgcat_cfg["general"]["port"] = pgcat.port
|
||||
pgcat.update_config(pgcat_cfg.deep_merge(extra_conf))
|
||||
pgcat.start
|
||||
|
||||
pgcat.wait_until_ready(pgcat.connection_string("sharded_db0", pg_user['username'], pg_user['password']))
|
||||
|
||||
OpenStruct.new.tap do |struct|
|
||||
struct.pgcat = pgcat
|
||||
struct.primary = primary
|
||||
struct.replicas = [replica]
|
||||
struct.all_databases = [primary]
|
||||
end
|
||||
end
|
||||
|
||||
def self.create_query_auth_function(user)
|
||||
return <<-SQL
|
||||
CREATE OR REPLACE FUNCTION public.user_lookup(in i_username text, out uname text, out phash text)
|
||||
RETURNS record AS $$
|
||||
BEGIN
|
||||
SELECT usename, passwd FROM pg_catalog.pg_shadow
|
||||
WHERE usename = i_username INTO uname, phash;
|
||||
RETURN;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.user_lookup(text) TO #{user};
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.exec_in_instances(query:, instance_ports: [ 5432, 10432 ], database: 'postgres', user: 'postgres', password: 'postgres')
|
||||
instance_ports.each do |port|
|
||||
c = PG.connect("postgres://#{user}:#{password}@localhost:#{port}/#{database}")
|
||||
c.exec(query)
|
||||
c.close
|
||||
end
|
||||
end
|
||||
|
||||
def self.set_up_auth_query_for_user(user:, password:, instance_ports: [ 5432, 10432 ], database: 'shard0' )
|
||||
instance_ports.each do |port|
|
||||
connection = PG.connect("postgres://postgres:postgres@localhost:#{port}/#{database}")
|
||||
connection.exec(self.drop_query_auth_function(user)) rescue PG::UndefinedFunction
|
||||
connection.exec("DROP ROLE #{user}") rescue PG::UndefinedObject
|
||||
connection.exec("CREATE ROLE #{user} ENCRYPTED PASSWORD '#{password}' LOGIN;")
|
||||
connection.exec(self.create_query_auth_function(user))
|
||||
connection.close
|
||||
end
|
||||
end
|
||||
|
||||
def self.tear_down_auth_query_for_user(user:, password:, instance_ports: [ 5432, 10432 ], database: 'shard0' )
|
||||
instance_ports.each do |port|
|
||||
connection = PG.connect("postgres://postgres:postgres@localhost:#{port}/#{database}")
|
||||
connection.exec(self.drop_query_auth_function(user)) rescue PG::UndefinedFunction
|
||||
connection.exec("DROP ROLE #{user}")
|
||||
connection.close
|
||||
end
|
||||
end
|
||||
|
||||
def self.drop_query_auth_function(user)
|
||||
return <<-SQL
|
||||
REVOKE ALL ON FUNCTION public.user_lookup(text) FROM public, #{user};
|
||||
DROP FUNCTION public.user_lookup(in i_username text, out uname text, out phash text);
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,13 @@ require 'ostruct'
|
||||
require_relative 'pgcat_process'
|
||||
require_relative 'pg_instance'
|
||||
|
||||
class ::Hash
|
||||
def deep_merge(second)
|
||||
merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
|
||||
self.merge(second, &merger)
|
||||
end
|
||||
end
|
||||
|
||||
module Helpers
|
||||
module Pgcat
|
||||
def self.three_shard_setup(pool_name, pool_size, pool_mode="transaction", lb_mode="random", log_level="info")
|
||||
|
||||
@@ -67,17 +67,21 @@ class PgcatProcess
|
||||
def start
|
||||
raise StandardError, "Process is already started" unless @pid.nil?
|
||||
@pid = Process.spawn(@env, @command, err: @log_filename, out: @log_filename)
|
||||
Process.detach(@pid)
|
||||
ObjectSpace.define_finalizer(@log_filename, proc { PgcatProcess.finalize(@pid, @log_filename, @config_filename) })
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
def wait_until_ready
|
||||
def wait_until_ready(connection_string = nil)
|
||||
exc = nil
|
||||
10.times do
|
||||
PG::connect(example_connection_string).close
|
||||
Process.kill 0, @pid
|
||||
PG::connect(connection_string || example_connection_string).close
|
||||
|
||||
return self
|
||||
rescue Errno::ESRCH
|
||||
raise StandardError, "Process #{@pid} died. #{logs}"
|
||||
rescue => e
|
||||
exc = e
|
||||
sleep(0.5)
|
||||
@@ -108,13 +112,10 @@ class PgcatProcess
|
||||
"postgresql://#{username}:#{password}@0.0.0.0:#{@port}/pgcat"
|
||||
end
|
||||
|
||||
def connection_string(pool_name, username)
|
||||
def connection_string(pool_name, username, password = nil)
|
||||
cfg = current_config
|
||||
|
||||
user_idx, user_obj = cfg["pools"][pool_name]["users"].detect { |k, user| user["username"] == username }
|
||||
password = user_obj["password"]
|
||||
|
||||
"postgresql://#{username}:#{password}@0.0.0.0:#{@port}/#{pool_name}"
|
||||
"postgresql://#{username}:#{password || user_obj["password"]}@0.0.0.0:#{@port}/#{pool_name}"
|
||||
end
|
||||
|
||||
def example_connection_string
|
||||
|
||||
Reference in New Issue
Block a user