diff --git a/ansible/files/postgresql_config/supautils.conf.j2 b/ansible/files/postgresql_config/supautils.conf.j2 index 984090544f..ffd2ac2c11 100644 --- a/ansible/files/postgresql_config/supautils.conf.j2 +++ b/ansible/files/postgresql_config/supautils.conf.j2 @@ -10,7 +10,7 @@ supautils.privileged_extensions = 'address_standardizer, address_standardizer_da supautils.extension_custom_scripts_path = '/etc/postgresql-custom/extension-custom-scripts' supautils.privileged_extensions_superuser = 'supabase_admin' supautils.privileged_role = 'supabase_privileged_role' -supautils.privileged_role_allowed_configs = 'auto_explain.*, deadlock_timeout, log_lock_waits, log_min_duration_statement, log_min_messages, log_parameter_max_length, log_replication_commands, log_statement, log_temp_files, pg_net.batch_size, pg_net.ttl, pg_stat_statements.*, pgaudit.log, pgaudit.log_catalog, pgaudit.log_client, pgaudit.log_level, pgaudit.log_relation, pgaudit.log_rows, pgaudit.log_statement, pgaudit.log_statement_once, pgaudit.role, pgrst.*, plan_filter.*, safeupdate.enabled, session_replication_role, track_functions, track_io_timing, wal_compression' +supautils.privileged_role_allowed_configs = 'auto_explain.*, deadlock_timeout, log_lock_waits, log_min_duration_statement, log_min_messages, log_parameter_max_length, log_replication_commands, log_statement, log_temp_files, pg_net.batch_size, pg_net.ttl, pg_stat_statements.*, pgaudit.log, pgaudit.log_catalog, pgaudit.log_client, pgaudit.log_level, pgaudit.log_relation, pgaudit.log_rows, pgaudit.log_statement, pgaudit.log_statement_once, pgaudit.role, pgrst.*, plan_filter.*, safeupdate.*, session_replication_role, track_functions, track_io_timing, wal_compression' supautils.reserved_memberships = 'pg_read_server_files, pg_write_server_files, pg_execute_server_program, supabase_admin, supabase_auth_admin, supabase_storage_admin, supabase_read_only_user, supabase_realtime_admin, supabase_replication_admin, supabase_etl_admin, dashboard_user, pgbouncer, authenticator' supautils.reserved_roles = 'supabase_admin, supabase_auth_admin, supabase_storage_admin, supabase_read_only_user, supabase_realtime_admin, supabase_replication_admin, supabase_etl_admin, dashboard_user, pgbouncer, service_role*, authenticator*, authenticated*, anon*' supautils.hint_roles = 'anon, authenticated, service_role' diff --git a/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql b/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql new file mode 100644 index 0000000000..fc854ee00a --- /dev/null +++ b/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER ROLE authenticated SET session_preload_libraries = 'safeupdate'; +ALTER ROLE anon SET session_preload_libraries = 'safeupdate'; +ALTER ROLE postgres SET session_preload_libraries = 'safeupdate'; +ALTER ROLE postgres SET safeupdate.enabled=0; + +-- migrate:down + diff --git a/migrations/tests/extensions/09-pg-safeupdate.sql b/migrations/tests/extensions/09-pg-safeupdate.sql index c08ec2ef9f..5a704cae88 100644 --- a/migrations/tests/extensions/09-pg-safeupdate.sql +++ b/migrations/tests/extensions/09-pg-safeupdate.sql @@ -1,3 +1,4 @@ BEGIN; -alter role postgres set session_preload_libraries = 'safeupdate'; +alter role postgres set session_preload_libraries = 'safeupdate, supautils'; +alter role postgres set safeupdate.enabled = 0; ROLLBACK; diff --git a/nix/ext/tests/pg_safeupdate.nix b/nix/ext/tests/pg_safeupdate.nix new file mode 100644 index 0000000000..5068b93428 --- /dev/null +++ b/nix/ext/tests/pg_safeupdate.nix @@ -0,0 +1,225 @@ +{ self, pkgs }: +let + pname = "safeupdate"; + inherit (pkgs) lib; + system = pkgs.pkgsLinux.stdenv.hostPlatform.system; + testLib = import ./lib.nix { inherit self pkgs; }; + installedExtension = + postgresMajorVersion: self.legacyPackages.${system}."psql_${postgresMajorVersion}".exts."${pname}"; + versions = postgresqlMajorVersion: (installedExtension postgresqlMajorVersion).versions; + orioledbVersions = self.legacyPackages.${system}."psql_orioledb-17".exts."${pname}".versions; +in +self.inputs.nixpkgs.lib.nixos.runTest { + name = pname; + hostPkgs = pkgs; + nodes.server = + { ... }: + { + imports = [ + (testLib.makeSupabaseTestConfig { + majorVersion = "15"; + }) + ]; + + specialisation.postgresql17.configuration = testLib.makeUpgradeSpecialisation { + fromMajorVersion = "15"; + toMajorVersion = "17"; + }; + + specialisation.orioledb17.configuration = testLib.makeOrioledbSpecialisation { }; + }; + testScript = + { nodes, ... }: + let + pg17-configuration = "${nodes.server.system.build.toplevel}/specialisation/postgresql17"; + orioledb17-configuration = "${nodes.server.system.build.toplevel}/specialisation/orioledb17"; + in + '' + from pathlib import Path + versions = { + "15": [${lib.concatStringsSep ", " (map (s: ''"${s}"'') (versions "15"))}], + "17": [${lib.concatStringsSep ", " (map (s: ''"${s}"'') (versions "17"))}], + "orioledb-17": [${lib.concatStringsSep ", " (map (s: ''"${s}"'') orioledbVersions)}], + } + extension_name = "${pname}" + support_upgrade = False + pg17_configuration = "${pg17-configuration}" + orioledb17_configuration = "${orioledb17-configuration}" + sql_test_directory = Path("${../../tests}") + + ${builtins.readFile ./lib.py} + + test = PostgresExtensionTest(server, extension_name, versions, sql_test_directory, support_upgrade) + + def setup_test_table(): + """Create a test table for safeupdate behavior tests.""" + test.run_sql("DROP TABLE IF EXISTS _test_safeupdate") + test.run_sql("CREATE TABLE _test_safeupdate (id int)") + test.run_sql("INSERT INTO _test_safeupdate VALUES (1)") + test.run_sql("GRANT ALL ON _test_safeupdate TO postgres") + + def cleanup_test_table(): + test.run_sql("DROP TABLE IF EXISTS _test_safeupdate") + + def check_role_config(): + """Verify session_preload_libraries is set for anon and authenticated roles.""" + anon_config = test.run_sql("SELECT rolconfig FROM pg_roles WHERE rolname = 'anon'") + assert "session_preload_libraries=safeupdate" in anon_config, ( + f"Expected safeupdate in anon session_preload_libraries, got: {anon_config}" + ) + + auth_config = test.run_sql("SELECT rolconfig FROM pg_roles WHERE rolname = 'authenticated'") + assert "session_preload_libraries=safeupdate" in auth_config, ( + f"Expected safeupdate in authenticated session_preload_libraries, got: {auth_config}" + ) + + def check_blocks_unsafe_operations(): + """Verify safeupdate blocks UPDATE/DELETE without WHERE when loaded.""" + setup_test_table() + + # UPDATE without WHERE should fail when safeupdate is loaded + server.fail( + """psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -c "LOAD 'safeupdate'" -c "UPDATE _test_safeupdate SET id = 2" """ + ) + + # UPDATE with WHERE should succeed + server.succeed( + """psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -c "LOAD 'safeupdate'" -c "UPDATE _test_safeupdate SET id = 2 WHERE id = 1" """ + ) + + # DELETE without WHERE should fail when safeupdate is loaded + server.fail( + """psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -c "DELETE FROM _test_safeupdate" """ + ) + + # DELETE with WHERE should succeed + server.succeed( + """psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -c "SET safeupdate.enabled=1" -c "DELETE FROM _test_safeupdate WHERE id = 2" """ + ) + + cleanup_test_table() + + def check_postgres_not_blocked(): + """Verify postgres is not blocked by default (safeupdate not in their session_preload_libraries).""" + test.run_sql("ALTER ROLE postgres SET session_preload_libraries = 'safeupdate'") + test.run_sql("ALTER ROLE postgres SET safeupdate.enabled = 1") + setup_test_table() + + server.succeed( + """psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 -c "UPDATE _test_safeupdate SET id = 2" """ + ) + server.succeed( + """psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 -c "DELETE FROM _test_safeupdate" """ + ) + + cleanup_test_table() + + def check_postgres_can_enable(): + """Verify postgres can opt-in to safeupdate for their role.""" + test.run_sql("ALTER ROLE postgres SET session_preload_libraries = 'safeupdate'") + test.run_sql("ALTER ROLE postgres SET safeupdate.enabled = 1") + + setup_test_table() + + # Now postgres should be blocked (new session picks up role setting) + server.fail( + """psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 -c "LOAD 'safeupdate'" -c "UPDATE _test_safeupdate SET id = 2" """ + ) + + # Clean up + test.run_sql("ALTER ROLE postgres RESET session_preload_libraries") + test.run_sql("ALTER ROLE postgres RESET safeupdate.enabled") + cleanup_test_table() + + start_all() + + server.wait_for_unit("supabase-db-init.service") + + with subtest("Verify PostgreSQL 15 is our custom build"): + pg_version = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SELECT version();\"" + ).strip() + assert "${testLib.expectedVersions."15"}" in pg_version, ( + f"Expected version ${testLib.expectedVersions."15"}, got: {pg_version}" + ) + + with subtest("Verify ansible config loaded"): + spl = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SHOW shared_preload_libraries;\"" + ).strip() + for ext in ["pg_stat_statements", "pgaudit", "pgsodium", "pg_cron", "pg_net"]: + assert ext in spl, f"Expected {ext} in shared_preload_libraries, got: {spl}" + + session_pl = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SHOW session_preload_libraries;\"" + ).strip() + assert "supautils" in session_pl, ( + f"Expected supautils in session_preload_libraries, got: {session_pl}" + ) + + with subtest("Verify init scripts and migrations ran"): + roles = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SELECT rolname FROM pg_roles ORDER BY rolname;\"" + ).strip() + for role in ["anon", "authenticated", "authenticator", "dashboard_user", "pgbouncer", "service_role", "supabase_admin", "supabase_auth_admin", "supabase_storage_admin"]: + assert role in roles, f"Expected role {role} to exist, got: {roles}" + + with subtest("Check safeupdate role configuration on PostgreSQL 15"): + check_role_config() + + with subtest("Check safeupdate blocks unsafe operations on PostgreSQL 15"): + check_blocks_unsafe_operations() + + with subtest("Check postgres is not blocked by default on PostgreSQL 15"): + check_postgres_not_blocked() + + with subtest("Check postgres can enable safeupdate on PostgreSQL 15"): + check_postgres_can_enable() + + with subtest("Switch to PostgreSQL 17"): + server.succeed( + f"{pg17_configuration}/bin/switch-to-configuration test >&2" + ) + server.wait_for_unit("postgresql.service") + + with subtest("Verify PostgreSQL 17 is our custom build"): + pg_version = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SELECT version();\"" + ).strip() + assert "${testLib.expectedVersions."17"}" in pg_version, ( + f"Expected version ${testLib.expectedVersions."17"}, got: {pg_version}" + ) + + with subtest("Check safeupdate role configuration on PostgreSQL 17"): + check_role_config() + + with subtest("Check safeupdate blocks unsafe operations on PostgreSQL 17"): + check_blocks_unsafe_operations() + + with subtest("Check postgres is not blocked by default on PostgreSQL 17"): + check_postgres_not_blocked() + + with subtest("Switch to OrioleDB 17"): + server.succeed( + f"{orioledb17_configuration}/bin/switch-to-configuration test >&2" + ) + server.wait_for_unit("supabase-db-init.service") + + with subtest("Verify OrioleDB is running"): + installed_extensions = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SELECT extname FROM pg_extension WHERE extname = 'orioledb';\"" + ).strip() + assert "orioledb" in installed_extensions, ( + f"Expected orioledb extension to be installed, got: {installed_extensions}" + ) + + with subtest("Check safeupdate role configuration on OrioleDB 17"): + check_role_config() + + with subtest("Check safeupdate blocks unsafe operations on OrioleDB 17"): + check_blocks_unsafe_operations() + + with subtest("Check postgres is not blocked by default on OrioleDB 17"): + check_postgres_not_blocked() + ''; +} diff --git a/nix/tests/expected/pg-safeupdate.out b/nix/tests/expected/pg-safeupdate.out index f9100116ac..28b69ef61e 100644 --- a/nix/tests/expected/pg-safeupdate.out +++ b/nix/tests/expected/pg-safeupdate.out @@ -1,3 +1,27 @@ +-- Verify anon role has safeupdate in session_preload_libraries +select exists( + select 1 from pg_db_role_setting s + join pg_roles r on r.oid = s.setrole + where r.rolname = 'anon' + and s.setconfig @> array['session_preload_libraries=safeupdate'] +) as anon_has_safeupdate; + anon_has_safeupdate +--------------------- + t +(1 row) + +-- Verify authenticated role has safeupdate in session_preload_libraries +select exists( + select 1 from pg_db_role_setting s + join pg_roles r on r.oid = s.setrole + where r.rolname = 'authenticated' + and s.setconfig @> array['session_preload_libraries=safeupdate'] +) as authenticated_has_safeupdate; + authenticated_has_safeupdate +------------------------------ + t +(1 row) + load 'safeupdate'; set safeupdate.enabled=1; create schema v; @@ -5,8 +29,28 @@ create table v.foo( id int, val text ); +insert into v.foo values (1, 'test'); +-- Should fail: UPDATE without WHERE update v.foo set val = 'bar'; ERROR: UPDATE requires a WHERE clause +-- Should succeed: UPDATE with WHERE +update v.foo + set val = 'bar' + where id = 1; +set safeupdate.enabled=0; +-- Should succeed +delete from v.foo; +grant all on schema v to authenticated; +grant all on v.foo to authenticated; +grant all on schema v to postgres; +grant all on v.foo to postgres; +set role authenticated; +-- Should fail: DELETE without WHERE +delete from v.foo; +-- Should succeed: DELETE with WHERE +delete from v.foo + where id = 1; +reset role; drop schema v cascade; NOTICE: drop cascades to table v.foo diff --git a/nix/tests/expected/roles.out b/nix/tests/expected/roles.out index a457f40297..148b845254 100644 --- a/nix/tests/expected/roles.out +++ b/nix/tests/expected/roles.out @@ -60,10 +60,10 @@ select from pg_roles r where rolname not in ('pg_create_subscription', 'pg_maintain', 'pg_use_reserved_connections') order by rolname; - rolname | rolconfig -----------------------------+--------------------------------------------------------------------------------- - anon | {statement_timeout=3s} - authenticated | {statement_timeout=8s} + rolname | rolconfig +----------------------------+----------------------------------------------------------------------------------------------------------- + anon | {statement_timeout=3s,session_preload_libraries=safeupdate} + authenticated | {statement_timeout=8s,session_preload_libraries=safeupdate} authenticator | {session_preload_libraries=safeupdate,statement_timeout=8s,lock_timeout=8s} dashboard_user | pg_checkpoint | @@ -83,7 +83,7 @@ order by rolname; pgsodium_keyiduser | pgsodium_keymaker | pgtle_admin | - postgres | {"search_path=\"\\$user\", public, extensions"} + postgres | {"search_path=\"\\$user\", public, extensions",session_preload_libraries=safeupdate,safeupdate.enabled=0} service_role | supabase_admin | {"search_path=\"$user\", public, auth, extensions",log_statement=none} supabase_auth_admin | {search_path=auth,idle_in_transaction_session_timeout=60000,log_statement=none} diff --git a/nix/tests/expected/z_multigres-orioledb-17_roles.out b/nix/tests/expected/z_multigres-orioledb-17_roles.out index a307b2014b..d1bd945e30 100644 --- a/nix/tests/expected/z_multigres-orioledb-17_roles.out +++ b/nix/tests/expected/z_multigres-orioledb-17_roles.out @@ -57,10 +57,10 @@ select from pg_roles r where rolname not in ('pg_create_subscription', 'pg_maintain', 'pg_use_reserved_connections') order by rolname; - rolname | rolconfig -----------------------------+--------------------------------------------------------------------------------- - anon | {statement_timeout=3s} - authenticated | {statement_timeout=8s} + rolname | rolconfig +----------------------------+----------------------------------------------------------------------------------------------------------- + anon | {statement_timeout=3s,session_preload_libraries=safeupdate} + authenticated | {statement_timeout=8s,session_preload_libraries=safeupdate} authenticator | {session_preload_libraries=safeupdate,statement_timeout=8s,lock_timeout=8s} dashboard_user | pg_checkpoint | @@ -77,7 +77,7 @@ order by rolname; pg_write_server_files | pgbouncer | pgtle_admin | - postgres | {"search_path=\"\\$user\", public, extensions"} + postgres | {"search_path=\"\\$user\", public, extensions",session_preload_libraries=safeupdate,safeupdate.enabled=0} service_role | supabase_admin | {"search_path=\"\\$user\", public, auth, extensions",log_statement=none} supabase_auth_admin | {search_path=auth,idle_in_transaction_session_timeout=60000,log_statement=none} diff --git a/nix/tests/sql/pg-safeupdate.sql b/nix/tests/sql/pg-safeupdate.sql index 790ec79fa1..361ba8d31f 100644 --- a/nix/tests/sql/pg-safeupdate.sql +++ b/nix/tests/sql/pg-safeupdate.sql @@ -1,3 +1,19 @@ +-- Verify anon role has safeupdate in session_preload_libraries +select exists( + select 1 from pg_db_role_setting s + join pg_roles r on r.oid = s.setrole + where r.rolname = 'anon' + and s.setconfig @> array['session_preload_libraries=safeupdate'] +) as anon_has_safeupdate; + +-- Verify authenticated role has safeupdate in session_preload_libraries +select exists( + select 1 from pg_db_role_setting s + join pg_roles r on r.oid = s.setrole + where r.rolname = 'authenticated' + and s.setconfig @> array['session_preload_libraries=safeupdate'] +) as authenticated_has_safeupdate; + load 'safeupdate'; set safeupdate.enabled=1; @@ -9,7 +25,34 @@ create table v.foo( val text ); +insert into v.foo values (1, 'test'); + +-- Should fail: UPDATE without WHERE update v.foo set val = 'bar'; +-- Should succeed: UPDATE with WHERE +update v.foo + set val = 'bar' + where id = 1; + +set safeupdate.enabled=0; + +-- Should succeed +delete from v.foo; + +grant all on schema v to authenticated; +grant all on v.foo to authenticated; +grant all on schema v to postgres; +grant all on v.foo to postgres; +set role authenticated; + +-- Should fail: DELETE without WHERE +delete from v.foo; + +-- Should succeed: DELETE with WHERE +delete from v.foo + where id = 1; + +reset role; drop schema v cascade;