From 490f3118068bf85a2bf054c5e18d2d9d90db1613 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:32:43 +0200 Subject: [PATCH 1/9] fix: load safeupdate but disable for all but Data API --- .../20260130074514_load_disable_pg_safeupdate.sql | 8 ++++++++ nix/tests/expected/pg-safeupdate.out | 5 +++++ nix/tests/expected/roles.out | 2 +- nix/tests/sql/pg-safeupdate.sql | 8 ++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql 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..2f89dc6d2b --- /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'; +load 'safeupdate'; + +SET safeupdate.enabled=0; + +-- migrate:down + diff --git a/nix/tests/expected/pg-safeupdate.out b/nix/tests/expected/pg-safeupdate.out index f9100116ac..0d17924d49 100644 --- a/nix/tests/expected/pg-safeupdate.out +++ b/nix/tests/expected/pg-safeupdate.out @@ -8,5 +8,10 @@ create table v.foo( update v.foo set val = 'bar'; ERROR: UPDATE requires a WHERE clause +grant all on schema v to authenticated; +set role authenticated; +delete from v.foo; +ERROR: DELETE requires a WHERE clause +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 0cd94fc830..9a001764ee 100644 --- a/nix/tests/expected/roles.out +++ b/nix/tests/expected/roles.out @@ -62,7 +62,7 @@ order by rolname; rolname | rolconfig ----------------------------+--------------------------------------------------------------------------------- anon | {statement_timeout=3s} - authenticated | {statement_timeout=8s} + authenticated | {statement_timeout=8s,session_preload_libraries=safeupdate} authenticator | {session_preload_libraries=safeupdate,statement_timeout=8s,lock_timeout=8s} dashboard_user | pg_checkpoint | diff --git a/nix/tests/sql/pg-safeupdate.sql b/nix/tests/sql/pg-safeupdate.sql index 790ec79fa1..6a7946e23f 100644 --- a/nix/tests/sql/pg-safeupdate.sql +++ b/nix/tests/sql/pg-safeupdate.sql @@ -12,4 +12,12 @@ create table v.foo( update v.foo set val = 'bar'; +grant all on schema v to authenticated; +set role authenticated; + +delete from v.foo; +reset role; drop schema v cascade; + + + From 01015019520fdfa02fcf8841381a947943601c25 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:34:43 +0200 Subject: [PATCH 2/9] fix: add anon role to safeupdate enabled automatically --- .../db/migrations/20260130074514_load_disable_pg_safeupdate.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql b/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql index 2f89dc6d2b..3b662c9c57 100644 --- a/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql +++ b/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql @@ -1,5 +1,6 @@ -- migrate:up ALTER ROLE authenticated SET session_preload_libraries = 'safeupdate'; +ALTER ROLE anon SET session_preload_libraries = 'safeupdate'; load 'safeupdate'; SET safeupdate.enabled=0; From 87bb02c11d8da95b332ee89f9decc0bcc3caaa80 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:44:19 +0200 Subject: [PATCH 3/9] rebase ext test changes from develop --- nix/ext/tests/pg_safeupdate.nix | 168 ++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 nix/ext/tests/pg_safeupdate.nix diff --git a/nix/ext/tests/pg_safeupdate.nix b/nix/ext/tests/pg_safeupdate.nix new file mode 100644 index 0000000000..cda7f22a12 --- /dev/null +++ b/nix/ext/tests/pg_safeupdate.nix @@ -0,0 +1,168 @@ +{ 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"; + 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}" + ext_has_background_worker = ${ + if (installedExtension "15") ? hasBackgroundWorker then "True" else "False" + } + sql_test_directory = Path("${../../tests}") + pg_regress_test_name = "${(installedExtension "15").pgRegressTestName or pname}" + + ${builtins.readFile ./lib.py} + + 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}" + ) + + postgres_path = server.succeed("readlink -f $(which postgres)").strip() + assert "postgresql-and-plugins-${testLib.expectedVersions."15"}" in postgres_path, ( + f"Expected our custom build (${testLib.expectedVersions."15"}), got: {postgres_path}" + ) + + 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}" + + schemas = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SELECT schema_name FROM information_schema.schemata ORDER BY schema_name;\"" + ).strip() + for schema in ["auth", "storage", "extensions"]: + assert schema in schemas, f"Expected schema {schema} to exist, got: {schemas}" + + test = PostgresExtensionTest(server, extension_name, versions, sql_test_directory, support_upgrade) + + with subtest("Check upgrade path with postgresql 15"): + test.check_upgrade_path("15") + + last_version = None + with subtest("Check the install of the last version of the extension"): + last_version = test.check_install_last_version("15") + + 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}" + ) + + postgres_pid = server.succeed( + "head -1 /var/lib/postgresql/data-17/postmaster.pid" + ).strip() + postgres_path = server.succeed( + f"readlink -f /proc/{postgres_pid}/exe" + ).strip() + assert "postgresql-and-plugins-${testLib.expectedVersions."17"}" in postgres_path, ( + f"Expected our custom build (${testLib.expectedVersions."17"}), got: {postgres_path}" + ) + + with subtest("Check last version of the extension after upgrade"): + test.assert_version_matches(last_version) + + with subtest("Check upgrade path with postgresql 17"): + test.check_upgrade_path("17") + + 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}" + ) + + dam = server.succeed( + "psql -U supabase_admin -d postgres -t -A -c \"SHOW default_table_access_method;\"" + ).strip() + assert dam == "orioledb", ( + f"Expected default_table_access_method = orioledb, got: {dam}" + ) + + with subtest("Verify OrioleDB 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", "supabase_admin"]: + assert role in roles, f"Expected role {role} to exist, got: {roles}" + + with subtest("Check upgrade path with orioledb 17"): + test.check_upgrade_path("orioledb-17") +} From 57fbf285b91f9ba1eea4f3cbbd6d324540c1a7e7 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:34:49 +0300 Subject: [PATCH 4/9] fix missing close quotes in pg-safeupdate test --- nix/ext/tests/pg_safeupdate.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/ext/tests/pg_safeupdate.nix b/nix/ext/tests/pg_safeupdate.nix index cda7f22a12..4b03e23976 100644 --- a/nix/ext/tests/pg_safeupdate.nix +++ b/nix/ext/tests/pg_safeupdate.nix @@ -165,4 +165,5 @@ self.inputs.nixpkgs.lib.nixos.runTest { with subtest("Check upgrade path with orioledb 17"): test.check_upgrade_path("orioledb-17") + '' } From 8d3dd2f4291e7b3ca58f0bca8a99ac42a486cf63 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:36:49 +0300 Subject: [PATCH 5/9] fix roles test for anon role --- migrations/tests/extensions/09-pg-safeupdate.sql | 2 +- nix/ext/tests/pg_safeupdate.nix | 4 ++-- nix/tests/expected/roles.out | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/migrations/tests/extensions/09-pg-safeupdate.sql b/migrations/tests/extensions/09-pg-safeupdate.sql index c08ec2ef9f..2fc90e9a1a 100644 --- a/migrations/tests/extensions/09-pg-safeupdate.sql +++ b/migrations/tests/extensions/09-pg-safeupdate.sql @@ -1,3 +1,3 @@ BEGIN; -alter role postgres set session_preload_libraries = 'safeupdate'; +alter role postgres set session_preload_libraries = 'safeupdate, supautils'; ROLLBACK; diff --git a/nix/ext/tests/pg_safeupdate.nix b/nix/ext/tests/pg_safeupdate.nix index 4b03e23976..af189c7085 100644 --- a/nix/ext/tests/pg_safeupdate.nix +++ b/nix/ext/tests/pg_safeupdate.nix @@ -13,7 +13,7 @@ self.inputs.nixpkgs.lib.nixos.runTest { name = pname; hostPkgs = pkgs; nodes.server = - { ... }: + { ... }: { imports = [ (testLib.makeSupabaseTestConfig { @@ -165,5 +165,5 @@ self.inputs.nixpkgs.lib.nixos.runTest { with subtest("Check upgrade path with orioledb 17"): test.check_upgrade_path("orioledb-17") - '' + ''; } diff --git a/nix/tests/expected/roles.out b/nix/tests/expected/roles.out index 1744389ba7..7603d946e4 100644 --- a/nix/tests/expected/roles.out +++ b/nix/tests/expected/roles.out @@ -62,7 +62,7 @@ where rolname not in ('pg_create_subscription', 'pg_maintain', 'pg_use_reserved_ order by rolname; rolname | rolconfig ----------------------------+--------------------------------------------------------------------------------- - anon | {statement_timeout=3s} + 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 | From 1c51dd388276fdbc03974d49f32afb505fc9eeac Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:26:55 +0300 Subject: [PATCH 6/9] add missing oriole config variable --- nix/ext/tests/pg_safeupdate.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/ext/tests/pg_safeupdate.nix b/nix/ext/tests/pg_safeupdate.nix index af189c7085..d666b8cce7 100644 --- a/nix/ext/tests/pg_safeupdate.nix +++ b/nix/ext/tests/pg_safeupdate.nix @@ -32,6 +32,7 @@ self.inputs.nixpkgs.lib.nixos.runTest { { 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 @@ -43,6 +44,7 @@ self.inputs.nixpkgs.lib.nixos.runTest { extension_name = "${pname}" support_upgrade = False pg17_configuration = "${pg17-configuration}" + orioledb17_configuration = "${orioledb17-configuration}" ext_has_background_worker = ${ if (installedExtension "15") ? hasBackgroundWorker then "True" else "False" } From 1328cfd903940c66f11410336dfccdb0c36423be Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:50:15 +0300 Subject: [PATCH 7/9] fix: load safeupdate for anon, authenticated and postgres. disable for postgres by default but allow postgres role to load it --- .../files/postgresql_config/supautils.conf.j2 | 2 +- ...60130074514_load_disable_pg_safeupdate.sql | 5 +- .../tests/extensions/09-pg-safeupdate.sql | 1 + nix/ext/tests/pg_safeupdate.nix | 166 ++++++++++++------ nix/tests/expected/pg-safeupdate.out | 41 ++++- nix/tests/expected/roles.out | 6 +- nix/tests/sql/pg-safeupdate.sql | 41 ++++- 7 files changed, 193 insertions(+), 69 deletions(-) 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 index 3b662c9c57..fc854ee00a 100644 --- a/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql +++ b/migrations/db/migrations/20260130074514_load_disable_pg_safeupdate.sql @@ -1,9 +1,8 @@ -- migrate:up ALTER ROLE authenticated SET session_preload_libraries = 'safeupdate'; ALTER ROLE anon SET session_preload_libraries = 'safeupdate'; -load 'safeupdate'; - -SET safeupdate.enabled=0; +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 2fc90e9a1a..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, 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 index d666b8cce7..577f1f298d 100644 --- a/nix/ext/tests/pg_safeupdate.nix +++ b/nix/ext/tests/pg_safeupdate.nix @@ -45,19 +45,92 @@ self.inputs.nixpkgs.lib.nixos.runTest { support_upgrade = False pg17_configuration = "${pg17-configuration}" orioledb17_configuration = "${orioledb17-configuration}" - ext_has_background_worker = ${ - if (installedExtension "15") ? hasBackgroundWorker then "True" else "False" - } sql_test_directory = Path("${../../tests}") - pg_regress_test_name = "${(installedExtension "15").pgRegressTestName or pname}" ${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 "LOAD 'safeupdate'" -c "DELETE FROM _test_safeupdate" """ + ) + + # DELETE with WHERE should succeed + server.succeed( + """psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -c "LOAD 'safeupdate'" -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).""" + 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'") + + 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 "UPDATE _test_safeupdate SET id = 2" """ + ) + + # Clean up + test.run_sql("ALTER ROLE postgres RESET session_preload_libraries") + 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();\"" @@ -66,11 +139,6 @@ self.inputs.nixpkgs.lib.nixos.runTest { f"Expected version ${testLib.expectedVersions."15"}, got: {pg_version}" ) - postgres_path = server.succeed("readlink -f $(which postgres)").strip() - assert "postgresql-and-plugins-${testLib.expectedVersions."15"}" in postgres_path, ( - f"Expected our custom build (${testLib.expectedVersions."15"}), got: {postgres_path}" - ) - with subtest("Verify ansible config loaded"): spl = server.succeed( "psql -U supabase_admin -d postgres -t -A -c \"SHOW shared_preload_libraries;\"" @@ -92,26 +160,23 @@ self.inputs.nixpkgs.lib.nixos.runTest { 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}" - schemas = server.succeed( - "psql -U supabase_admin -d postgres -t -A -c \"SELECT schema_name FROM information_schema.schemata ORDER BY schema_name;\"" - ).strip() - for schema in ["auth", "storage", "extensions"]: - assert schema in schemas, f"Expected schema {schema} to exist, got: {schemas}" + with subtest("Check safeupdate role configuration on PostgreSQL 15"): + check_role_config() - test = PostgresExtensionTest(server, extension_name, versions, sql_test_directory, support_upgrade) + with subtest("Check safeupdate blocks unsafe operations on PostgreSQL 15"): + check_blocks_unsafe_operations() - with subtest("Check upgrade path with postgresql 15"): - test.check_upgrade_path("15") + with subtest("Check postgres is not blocked by default on PostgreSQL 15"): + check_postgres_not_blocked() - last_version = None - with subtest("Check the install of the last version of the extension"): - last_version = test.check_install_last_version("15") + 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("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( @@ -121,27 +186,20 @@ self.inputs.nixpkgs.lib.nixos.runTest { f"Expected version ${testLib.expectedVersions."17"}, got: {pg_version}" ) - postgres_pid = server.succeed( - "head -1 /var/lib/postgresql/data-17/postmaster.pid" - ).strip() - postgres_path = server.succeed( - f"readlink -f /proc/{postgres_pid}/exe" - ).strip() - assert "postgresql-and-plugins-${testLib.expectedVersions."17"}" in postgres_path, ( - f"Expected our custom build (${testLib.expectedVersions."17"}), got: {postgres_path}" - ) + with subtest("Check safeupdate role configuration on PostgreSQL 17"): + check_role_config() - with subtest("Check last version of the extension after upgrade"): - test.assert_version_matches(last_version) + with subtest("Check safeupdate blocks unsafe operations on PostgreSQL 17"): + check_blocks_unsafe_operations() - with subtest("Check upgrade path with postgresql 17"): - test.check_upgrade_path("17") + 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("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( @@ -151,21 +209,13 @@ self.inputs.nixpkgs.lib.nixos.runTest { f"Expected orioledb extension to be installed, got: {installed_extensions}" ) - dam = server.succeed( - "psql -U supabase_admin -d postgres -t -A -c \"SHOW default_table_access_method;\"" - ).strip() - assert dam == "orioledb", ( - f"Expected default_table_access_method = orioledb, got: {dam}" - ) + with subtest("Check safeupdate role configuration on OrioleDB 17"): + check_role_config() - with subtest("Verify OrioleDB 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", "supabase_admin"]: - assert role in roles, f"Expected role {role} to exist, got: {roles}" + with subtest("Check safeupdate blocks unsafe operations on OrioleDB 17"): + check_blocks_unsafe_operations() - with subtest("Check upgrade path with orioledb 17"): - test.check_upgrade_path("orioledb-17") + 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 0d17924d49..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,13 +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; -ERROR: DELETE requires a WHERE clause +-- 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 7603d946e4..148b845254 100644 --- a/nix/tests/expected/roles.out +++ b/nix/tests/expected/roles.out @@ -60,8 +60,8 @@ select from pg_roles r where rolname not in ('pg_create_subscription', 'pg_maintain', 'pg_use_reserved_connections') order by rolname; - rolname | rolconfig -----------------------------+--------------------------------------------------------------------------------- + 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} @@ -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/sql/pg-safeupdate.sql b/nix/tests/sql/pg-safeupdate.sql index 6a7946e23f..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,15 +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; -reset role; -drop schema v cascade; - +-- Should succeed: DELETE with WHERE +delete from v.foo + where id = 1; +reset role; +drop schema v cascade; From c6e17a170b623a89d5681481f6510697c2632375 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:03:25 +0300 Subject: [PATCH 8/9] add role changes to multigres/oriole checks --- nix/tests/expected/z_multigres-orioledb-17_roles.out | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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} From 2ca0c25297b75e41835044e800a226ef65b513cf Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:52:52 +0300 Subject: [PATCH 9/9] fix nix tests --- nix/ext/tests/pg_safeupdate.nix | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nix/ext/tests/pg_safeupdate.nix b/nix/ext/tests/pg_safeupdate.nix index 577f1f298d..5068b93428 100644 --- a/nix/ext/tests/pg_safeupdate.nix +++ b/nix/ext/tests/pg_safeupdate.nix @@ -89,18 +89,20 @@ self.inputs.nixpkgs.lib.nixos.runTest { # DELETE 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 "DELETE FROM _test_safeupdate" """ + """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 "LOAD 'safeupdate'" -c "DELETE FROM _test_safeupdate WHERE id = 2" """ + """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( @@ -115,16 +117,18 @@ self.inputs.nixpkgs.lib.nixos.runTest { 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 "UPDATE _test_safeupdate SET id = 2" """ + """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()