# Copyright (c) 2021-2023, PostgreSQL Global Development Group # Test that logical replication respects permissions use strict; use warnings; use PostgreSQL::Test::Cluster; use PostgreSQL::Test::Utils; use Test::More; my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset); $offset = 0; sub publish_insert { my ($tbl, $new_i) = @_; $node_publisher->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_alice; INSERT INTO $tbl (i) VALUES ($new_i); )); } sub publish_update { my ($tbl, $old_i, $new_i) = @_; $node_publisher->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_alice; UPDATE $tbl SET i = $new_i WHERE i = $old_i; )); } sub publish_delete { my ($tbl, $old_i) = @_; $node_publisher->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_alice; DELETE FROM $tbl WHERE i = $old_i; )); } sub expect_replication { my ($tbl, $cnt, $min, $max, $testname) = @_; $node_publisher->wait_for_catchup('admin_sub'); $result = $node_subscriber->safe_psql( 'postgres', qq( SELECT COUNT(i), MIN(i), MAX(i) FROM $tbl)); is($result, "$cnt|$min|$max", $testname); } sub expect_failure { my ($tbl, $cnt, $min, $max, $re, $testname) = @_; $offset = $node_subscriber->wait_for_log($re, $offset); $result = $node_subscriber->safe_psql( 'postgres', qq( SELECT COUNT(i), MIN(i), MAX(i) FROM $tbl)); is($result, "$cnt|$min|$max", $testname); } sub revoke_superuser { my ($role) = @_; $node_subscriber->safe_psql( 'postgres', qq( ALTER ROLE $role NOSUPERUSER)); } sub grant_superuser { my ($role) = @_; $node_subscriber->safe_psql( 'postgres', qq( ALTER ROLE $role SUPERUSER)); } # Create publisher and subscriber nodes with schemas owned and published by # "regress_alice" but subscribed and replicated by different role # "regress_admin". For partitioned tables, layout the partitions differently # on the publisher than on the subscriber. # $node_publisher = PostgreSQL::Test::Cluster->new('publisher'); $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber'); $node_publisher->init(allows_streaming => 'logical'); $node_subscriber->init; $node_publisher->start; $node_subscriber->start; $publisher_connstr = $node_publisher->connstr . ' dbname=postgres'; my %remainder_a = ( publisher => 0, subscriber => 1); my %remainder_b = ( publisher => 1, subscriber => 0); for my $node ($node_publisher, $node_subscriber) { my $remainder_a = $remainder_a{ $node->name }; my $remainder_b = $remainder_b{ $node->name }; $node->safe_psql( 'postgres', qq( CREATE ROLE regress_admin SUPERUSER LOGIN; CREATE ROLE regress_alice NOSUPERUSER LOGIN; GRANT CREATE ON DATABASE postgres TO regress_alice; SET SESSION AUTHORIZATION regress_alice; CREATE SCHEMA alice; GRANT USAGE ON SCHEMA alice TO regress_admin; CREATE TABLE alice.unpartitioned (i INTEGER); ALTER TABLE alice.unpartitioned REPLICA IDENTITY FULL; GRANT SELECT ON TABLE alice.unpartitioned TO regress_admin; CREATE TABLE alice.hashpart (i INTEGER) PARTITION BY HASH (i); ALTER TABLE alice.hashpart REPLICA IDENTITY FULL; GRANT SELECT ON TABLE alice.hashpart TO regress_admin; CREATE TABLE alice.hashpart_a PARTITION OF alice.hashpart FOR VALUES WITH (MODULUS 2, REMAINDER $remainder_a); ALTER TABLE alice.hashpart_a REPLICA IDENTITY FULL; CREATE TABLE alice.hashpart_b PARTITION OF alice.hashpart FOR VALUES WITH (MODULUS 2, REMAINDER $remainder_b); ALTER TABLE alice.hashpart_b REPLICA IDENTITY FULL; )); } $node_publisher->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_alice; CREATE PUBLICATION alice FOR TABLE alice.unpartitioned, alice.hashpart WITH (publish_via_partition_root = true); )); $node_subscriber->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_admin; CREATE SUBSCRIPTION admin_sub CONNECTION '$publisher_connstr' PUBLICATION alice WITH (password_required=false); )); # Wait for initial sync to finish $node_subscriber->wait_for_subscription_sync($node_publisher, 'admin_sub'); # Verify that "regress_admin" can replicate into the tables # publish_insert("alice.unpartitioned", 1); publish_insert("alice.unpartitioned", 3); publish_insert("alice.unpartitioned", 5); publish_update("alice.unpartitioned", 1 => 7); publish_delete("alice.unpartitioned", 3); expect_replication("alice.unpartitioned", 2, 5, 7, "superuser admin replicates into unpartitioned"); # Revoke and restore superuser privilege for "regress_admin", # verifying that replication fails while superuser privilege is # missing, but works again and catches up once superuser is restored. # revoke_superuser("regress_admin"); publish_update("alice.unpartitioned", 5 => 9); expect_failure( "alice.unpartitioned", 2, 5, 7, qr/ERROR: ( [A-Z0-9]+:)? role "regress_admin" cannot SET ROLE to "regress_alice"/msi, "non-superuser admin fails to replicate update"); grant_superuser("regress_admin"); expect_replication("alice.unpartitioned", 2, 7, 9, "admin with restored superuser privilege replicates update"); # Privileges on the target role suffice for non-superuser replication. $node_subscriber->safe_psql( 'postgres', qq( ALTER ROLE regress_admin NOSUPERUSER; GRANT regress_alice TO regress_admin; )); publish_insert("alice.unpartitioned", 11); expect_replication("alice.unpartitioned", 3, 7, 11, "nosuperuser admin with privileges on role can replicate INSERT into unpartitioned" ); publish_update("alice.unpartitioned", 7 => 13); expect_replication("alice.unpartitioned", 3, 9, 13, "nosuperuser admin with privileges on role can replicate UPDATE into unpartitioned" ); publish_delete("alice.unpartitioned", 9); expect_replication("alice.unpartitioned", 2, 11, 13, "nosuperuser admin with privileges on role can replicate DELETE into unpartitioned" ); # Test partitioning # publish_insert("alice.hashpart", 101); publish_insert("alice.hashpart", 102); publish_insert("alice.hashpart", 103); publish_update("alice.hashpart", 102 => 120); publish_delete("alice.hashpart", 101); expect_replication("alice.hashpart", 2, 103, 120, "nosuperuser admin with privileges on role can replicate into hashpart"); # Force RLS on the target table and check that replication fails. $node_subscriber->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_alice; ALTER TABLE alice.unpartitioned ENABLE ROW LEVEL SECURITY; ALTER TABLE alice.unpartitioned FORCE ROW LEVEL SECURITY; )); publish_insert("alice.unpartitioned", 15); expect_failure( "alice.unpartitioned", 2, 11, 13, qr/ERROR: ( [A-Z0-9]+:)? user "regress_alice" cannot replicate into relation with row-level security enabled: "unpartitioned\w*"/msi, "replication of insert into table with forced rls fails"); # Since replication acts as the table owner, replication will succeed if we don't force it. $node_subscriber->safe_psql( 'postgres', qq( ALTER TABLE alice.unpartitioned NO FORCE ROW LEVEL SECURITY; )); expect_replication("alice.unpartitioned", 3, 11, 15, "non-superuser admin can replicate insert if rls is not forced"); $node_subscriber->safe_psql( 'postgres', qq( ALTER TABLE alice.unpartitioned FORCE ROW LEVEL SECURITY; )); publish_update("alice.unpartitioned", 11 => 17); expect_failure( "alice.unpartitioned", 3, 11, 15, qr/ERROR: ( [A-Z0-9]+:)? user "regress_alice" cannot replicate into relation with row-level security enabled: "unpartitioned\w*"/msi, "replication of update into table with forced rls fails"); $node_subscriber->safe_psql( 'postgres', qq( ALTER TABLE alice.unpartitioned NO FORCE ROW LEVEL SECURITY; )); expect_replication("alice.unpartitioned", 3, 13, 17, "non-superuser admin can replicate update if rls is not forced"); # Remove some of alice's privileges on her own table. Then replication should fail. $node_subscriber->safe_psql( 'postgres', qq( REVOKE SELECT, INSERT ON alice.unpartitioned FROM regress_alice; )); publish_insert("alice.unpartitioned", 19); expect_failure( "alice.unpartitioned", 3, 13, 17, qr/ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned/msi, "replication of insert fails if table owner lacks insert permission"); # alice needs INSERT but not SELECT to replicate an INSERT. $node_subscriber->safe_psql( 'postgres', qq( GRANT INSERT ON alice.unpartitioned TO regress_alice; )); expect_replication("alice.unpartitioned", 4, 13, 19, "restoring insert permission permits replication to continue"); # Now let's try an UPDATE and a DELETE. $node_subscriber->safe_psql( 'postgres', qq( REVOKE UPDATE, DELETE ON alice.unpartitioned FROM regress_alice; )); publish_update("alice.unpartitioned", 13 => 21); publish_delete("alice.unpartitioned", 15); expect_failure( "alice.unpartitioned", 4, 13, 19, qr/ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned/msi, "replication of update/delete fails if table owner lacks corresponding permission" ); # Restoring UPDATE and DELETE is insufficient. $node_subscriber->safe_psql( 'postgres', qq( GRANT UPDATE, DELETE ON alice.unpartitioned TO regress_alice; )); expect_failure( "alice.unpartitioned", 4, 13, 19, qr/ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned/msi, "replication of update/delete fails if table owner lacks SELECT permission" ); # alice needs INSERT but not SELECT to replicate an INSERT. $node_subscriber->safe_psql( 'postgres', qq( GRANT SELECT ON alice.unpartitioned TO regress_alice; )); expect_replication("alice.unpartitioned", 3, 17, 21, "restoring SELECT permission permits replication to continue"); # If the subscription connection requires a password ('password_required' # is true) then a non-superuser must specify that password in the connection # string. SKIP: { skip "subscription password_required test cannot run without Unix-domain sockets", 3 unless $use_unix_sockets; my $node_publisher1 = PostgreSQL::Test::Cluster->new('publisher1'); my $node_subscriber1 = PostgreSQL::Test::Cluster->new('subscriber1'); $node_publisher1->init(allows_streaming => 'logical'); $node_subscriber1->init; $node_publisher1->start; $node_subscriber1->start; my $publisher_connstr1 = $node_publisher1->connstr . ' user=regress_test_user dbname=postgres'; my $publisher_connstr2 = $node_publisher1->connstr . ' user=regress_test_user dbname=postgres password=secret'; for my $node ($node_publisher1, $node_subscriber1) { $node->safe_psql( 'postgres', qq( CREATE ROLE regress_test_user PASSWORD 'secret' LOGIN REPLICATION; GRANT CREATE ON DATABASE postgres TO regress_test_user; GRANT PG_CREATE_SUBSCRIPTION TO regress_test_user; )); } $node_publisher1->safe_psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_test_user; CREATE PUBLICATION regress_test_pub; )); $node_subscriber1->safe_psql( 'postgres', qq( CREATE SUBSCRIPTION regress_test_sub CONNECTION '$publisher_connstr1' PUBLICATION regress_test_pub; )); # Wait for initial sync to finish $node_subscriber1->wait_for_subscription_sync($node_publisher1, 'regress_test_sub'); my $save_pgpassword = $ENV{"PGPASSWORD"}; $ENV{"PGPASSWORD"} = 'secret'; # Setup pg_hba configuration so that logical replication connection without # password is not allowed. unlink($node_publisher1->data_dir . '/pg_hba.conf'); $node_publisher1->append_conf('pg_hba.conf', qq{local all regress_test_user md5}); $node_publisher1->reload; # Change the subscription owner to a non-superuser $node_subscriber1->safe_psql( 'postgres', qq( ALTER SUBSCRIPTION regress_test_sub OWNER TO regress_test_user; )); # Non-superuser must specify password in the connection string my ($ret, $stdout, $stderr) = $node_subscriber1->psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_test_user; ALTER SUBSCRIPTION regress_test_sub REFRESH PUBLICATION; )); isnt($ret, 0, "non zero exit for subscription whose owner is a non-superuser must specify password parameter of the connection string" ); ok( $stderr =~ m/DETAIL: Non-superusers must provide a password in the connection string./, 'subscription whose owner is a non-superuser must specify password parameter of the connection string' ); $ENV{"PGPASSWORD"} = $save_pgpassword; # It should succeed after including the password parameter of the connection # string. ($ret, $stdout, $stderr) = $node_subscriber1->psql( 'postgres', qq( SET SESSION AUTHORIZATION regress_test_user; ALTER SUBSCRIPTION regress_test_sub CONNECTION '$publisher_connstr2'; ALTER SUBSCRIPTION regress_test_sub REFRESH PUBLICATION; )); is($ret, 0, "Non-superuser will be able to refresh the publication after specifying the password parameter of the connection string" ); } done_testing();