diff --git a/Dockerfile b/Dockerfile index 0293c4f..e9a7568 100644 --- a/Dockerfile +++ b/Dockerfile @@ -133,6 +133,20 @@ EOF ENV PGDATA=/var/lib/pgsql/${POSTGRES_MAJOR_VERSION}/data RUN install --verbose --directory --owner postgres --group postgres --mode 1777 "$PGDATA" +# supautils.extension_custom_scripts_path scripts. Baked into the image +# rather than a runtime mount: supautils reads these from a plain +# filesystem path with no other configuration hook available. See +# extension-custom-scripts/README.md for the convention new scripts +# follow. +# +# This only places the scripts at a well-known path; it does not load +# supautils or point supautils.extension_custom_scripts_path at this +# directory. Neither is set anywhere in this image, deliberately, the +# same as every other supautils.* setting here: shared_preload_libraries +# is empty by default, so a deployment that wants any of this configures +# it itself in its own postgresql.conf or equivalent. +COPY --chown=postgres:postgres extension-custom-scripts /etc/pgedge/extension-custom-scripts + USER postgres ENV PG_MAJOR=${POSTGRES_MAJOR_VERSION} diff --git a/extension-custom-scripts/README.md b/extension-custom-scripts/README.md new file mode 100644 index 0000000..5230a22 --- /dev/null +++ b/extension-custom-scripts/README.md @@ -0,0 +1,28 @@ +# extension-custom-scripts + +Scripts for `supautils.extension_custom_scripts_path`, baked into the +`standard` image at `/etc/pgedge/extension-custom-scripts`. supautils +runs these around `CREATE EXTENSION`, as the superuser session it +already switches to for a privileged install (see +`supautils.superuser`), so a script here can assume superuser +privileges, not just the installing role's own. + +Layout, per [supautils' own convention](https://github.com/supabase/supautils#readme): + +``` +extension-custom-scripts/ + / + before-create.sql # optional, runs before CREATE EXTENSION + after-create.sql # optional, runs after CREATE EXTENSION +``` + +This image is not exclusive to any one deployment's role model, and an +extension can be installed into any database, owned by whatever role +happens to own it. A script granting access to "the role that should be +able to use this" should grant to +[`pg_database_owner`](https://www.postgresql.org/docs/current/predefined-roles.html#PREDEFINED-ROLE-PG-DATABASE-OWNER), +not a hardcoded role name: Postgres automatically maintains membership +in this predefined role to match whoever currently owns the database, +so the grant keeps working even if that database is later reassigned +to a different owner, and needs no assumption about what the owner is +named. See `pg_cron`'s `after-create.sql` for the pattern. diff --git a/extension-custom-scripts/address_standardizer_data_us/after-create.sql b/extension-custom-scripts/address_standardizer_data_us/after-create.sql new file mode 100644 index 0000000..ebeda3b --- /dev/null +++ b/extension-custom-scripts/address_standardizer_data_us/after-create.sql @@ -0,0 +1,53 @@ +-- us_lex/us_gaz/us_rules land wherever the extension itself was +-- installed, owned by the supautils superuser. Unlike the other five +-- scripts in this directory, this extension is relocatable +-- (control file has no fixed schema), so a caller can run +-- CREATE EXTENSION address_standardizer_data_us SCHEMA gis and these +-- three tables land in gis, not public. A hardcoded public.us_lex +-- here would silently fail against relations that don't exist in +-- that case, which is why this looks the schema up at runtime rather +-- than assuming it. +-- +-- Looked up via pg_extension.extnamespace rather than supautils' own +-- @extschema@ substitution: that token is only populated when the +-- caller's CREATE EXTENSION included an explicit SCHEMA clause, and +-- is otherwise substituted as SQL NULL, which is the common case +-- (no explicit SCHEMA at all). pg_extension.extnamespace is populated +-- unconditionally, by Postgres itself, once the extension exists, so +-- it covers both cases with the same query. +-- +-- Granted to pg_database_owner rather than a hardcoded role name, so +-- this keeps working if the database is later reassigned to a +-- different owner. See +-- https://www.postgresql.org/docs/current/predefined-roles.html. +-- +-- Also grants USAGE on the schema itself, not just SELECT on the +-- tables: table-level SELECT alone is not enough to query a table +-- outside the search path, Postgres separately checks USAGE on the +-- schema before it will even look a table up in it. The default, +-- unrelocated case (public) happens to work without this, since +-- public grants USAGE to PUBLIC by default, but a schema named on an +-- explicit SCHEMA clause has no such default and would otherwise +-- leave pg_database_owner with a grant it can never actually use. +-- +-- Also granted to PUBLIC: pg_database_owner's own grant carries no +-- GRANT OPTION, so there is no way to pass it on to another role +-- afterward, and the attempt is a silent no-op, not an error. +DO $$ +DECLARE ext_schema name; +BEGIN + SELECT n.nspname INTO ext_schema + FROM pg_catalog.pg_extension e + JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'address_standardizer_data_us'; + + EXECUTE format( + 'GRANT USAGE ON SCHEMA %I TO pg_database_owner, PUBLIC', + ext_schema + ); + EXECUTE format( + 'GRANT SELECT ON TABLE %I.us_lex, %I.us_gaz, %I.us_rules TO pg_database_owner, PUBLIC', + ext_schema, ext_schema, ext_schema + ); +END +$$; diff --git a/extension-custom-scripts/pg_cron/after-create.sql b/extension-custom-scripts/pg_cron/after-create.sql new file mode 100644 index 0000000..77d8db2 --- /dev/null +++ b/extension-custom-scripts/pg_cron/after-create.sql @@ -0,0 +1,26 @@ +-- supautils runs this immediately after CREATE EXTENSION pg_cron, as the +-- same superuser session used to install the extension (see +-- supautils.superuser). pg_cron's install script creates cron.job and +-- cron.job_run_details owned by that superuser, leaving the current +-- database's own owner with no path to manage its own scheduled jobs +-- or review their run history. +-- +-- Granted to pg_database_owner rather than a hardcoded role name: +-- Postgres automatically maintains membership in this predefined role to +-- match whoever currently owns the database pg_cron was installed in, so +-- this keeps working correctly if that database is later reassigned to a +-- different owner, and needs no assumption about what that owner is +-- named. See https://www.postgresql.org/docs/current/predefined-roles.html. +-- +-- SELECT only, ownership stays with the installing superuser: +-- cron.schedule() and cron.unschedule() are not SECURITY DEFINER, they +-- run as the caller, but they write to cron.job through pg_cron's own +-- internal C code, not through a normal caller-privileged INSERT or +-- UPDATE. Confirmed directly: a role with only this SELECT grant can +-- schedule, list, and unschedule its own jobs through those functions, +-- and a raw INSERT or UPDATE against cron.job as that role is refused +-- outright, permission denied, with no ownership or row-level-security +-- involved at all. Nothing here ever needs ownership to work. +GRANT USAGE ON SCHEMA cron TO pg_database_owner; +GRANT SELECT ON cron.job TO pg_database_owner; +GRANT SELECT ON cron.job_run_details TO pg_database_owner; diff --git a/extension-custom-scripts/pg_tokenizer/after-create.sql b/extension-custom-scripts/pg_tokenizer/after-create.sql new file mode 100644 index 0000000..c58996d --- /dev/null +++ b/extension-custom-scripts/pg_tokenizer/after-create.sql @@ -0,0 +1,75 @@ +-- Read access to admin-configured tokenizer definitions +-- (tokenizer_catalog.*), not write: whoever configures tokenizers +-- stays a separate, more privileged concern. Scoped to this one +-- schema, not a database-wide default, so a future gated extension's +-- own schema isn't exposed without its own deliberate grant here. +-- +-- Also granted to PUBLIC: pg_database_owner's own grant carries no +-- GRANT OPTION, so there is no way to pass it on to another role +-- afterward, and the attempt is a silent no-op, not an error. The +-- write side stays restricted to pg_database_owner only. +GRANT USAGE ON SCHEMA tokenizer_catalog TO pg_database_owner, PUBLIC; +GRANT SELECT ON ALL TABLES IN SCHEMA tokenizer_catalog TO pg_database_owner, PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA tokenizer_catalog + GRANT SELECT ON TABLES TO pg_database_owner, PUBLIC; + +-- Schema USAGE does not just unlock reading the tables above, it +-- makes every function in this schema callable by any role, since +-- Postgres grants EXECUTE on new functions to PUBLIC by default. Most +-- of what lives here manages tokenizer/model configuration +-- (create_*, drop_*, add_preload_model, and friends), none of it +-- SECURITY DEFINER, so the table writes those functions attempt are +-- still refused on ACL, confirmed directly. But create_huggingface_model +-- and create_lindera_model run real work, parsing a config and +-- attempting to load a model, before any permission check fires, and +-- a role with only schema USAGE can reach them now. Revokes EXECUTE +-- from PUBLIC on everything in the schema, keeps it for +-- pg_database_owner explicitly rather than leaving it dependent on +-- the PUBLIC default just revoked, then re-grants PUBLIC only the +-- three functions the read-only use case actually needs: tokenize() +-- and apply_text_analyzer() to process text against an existing +-- configuration, and list_preload_models() to see what is available. +-- Configuring a new tokenizer, model, or analyzer stays a privileged +-- operation. +REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA tokenizer_catalog FROM PUBLIC; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tokenizer_catalog TO pg_database_owner; +GRANT EXECUTE ON FUNCTION tokenizer_catalog.tokenize(text, text) TO PUBLIC; +GRANT EXECUTE ON FUNCTION tokenizer_catalog.apply_text_analyzer(text, text) TO PUBLIC; +GRANT EXECUTE ON FUNCTION tokenizer_catalog.list_preload_models() TO PUBLIC; + +-- Postgres seeds every new function with PUBLIC execute regardless of +-- ALTER DEFAULT PRIVILEGES, which cannot override that built-in +-- default, only add to it. A function an extension upgrade adds later +-- is not covered by the REVOKE above, since that only reaches +-- functions that already exist when this script runs, so anything +-- added afterward is locked down here instead: any CREATE or REPLACE +-- of a function in this schema drops PUBLIC execute and grants +-- pg_database_owner, the same posture the REVOKE above establishes at +-- install time. This also re-locks the three functions granted to +-- PUBLIC above if a future version of the extension replaces them, +-- so reopening those after an upgrade is a deliberate step, not +-- something that happens on its own. supautils skips an event trigger +-- for a superuser that does not own its function, so this does not +-- fire for a function added by a superuser other than the one that +-- ran this script. +CREATE OR REPLACE FUNCTION tokenizer_catalog.lock_down_new_function() +RETURNS event_trigger +LANGUAGE plpgsql +AS $lockdown$ +DECLARE + obj record; +BEGIN + FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP + IF obj.object_type = 'function' AND obj.schema_name = 'tokenizer_catalog' THEN + EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC', obj.object_identity); + EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO pg_database_owner', obj.object_identity); + END IF; + END LOOP; +END; +$lockdown$; + +DROP EVENT TRIGGER IF EXISTS tokenizer_catalog_lock_new_functions; +CREATE EVENT TRIGGER tokenizer_catalog_lock_new_functions + ON ddl_command_end + WHEN TAG IN ('CREATE FUNCTION') + EXECUTE FUNCTION tokenizer_catalog.lock_down_new_function(); diff --git a/extension-custom-scripts/postgis_tiger_geocoder/after-create.sql b/extension-custom-scripts/postgis_tiger_geocoder/after-create.sql new file mode 100644 index 0000000..3ae120a --- /dev/null +++ b/extension-custom-scripts/postgis_tiger_geocoder/after-create.sql @@ -0,0 +1,12 @@ +-- Read access to the reference tables the extension's own functions +-- query (tiger.*, created at CREATE EXTENSION time). The actual +-- Census dataset a user bulk-loads afterward is a separate step this +-- script cannot reach, it only runs once, at CREATE EXTENSION time. +-- +-- Also granted to PUBLIC: pg_database_owner's own grant carries no +-- GRANT OPTION, so there is no way to pass it on to another role +-- afterward, and the attempt is a silent no-op, not an error. +GRANT USAGE ON SCHEMA tiger TO pg_database_owner, PUBLIC; +GRANT SELECT ON ALL TABLES IN SCHEMA tiger TO pg_database_owner, PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA tiger + GRANT SELECT ON TABLES TO pg_database_owner, PUBLIC; diff --git a/extension-custom-scripts/postgis_topology/after-create.sql b/extension-custom-scripts/postgis_topology/after-create.sql new file mode 100644 index 0000000..0f4bd40 --- /dev/null +++ b/extension-custom-scripts/postgis_topology/after-create.sql @@ -0,0 +1,27 @@ +-- PostGIS's own install script grants PUBLIC only read access to the +-- topology schema (USAGE on the schema, SELECT on its tables), enough +-- to read an existing topology, not to manage one. The extension's +-- actual purpose needs a lot more than INSERT on topology.topology and +-- topology.layer: DropTopology()/DropTopoGeometryColumn() DELETE from +-- both, RenameTopology()/RenameTopoGeometryColumn() UPDATE them, and +-- RenameTopoGeometryColumn() additionally runs ALTER TABLE ... +-- DISABLE/ENABLE TRIGGER on topology.layer, which Postgres never +-- grants, only an owner (or superuser) can do it. Unlike pg_cron, +-- whose write functions bypass ACL checks through internal C code, +-- postgis_topology's functions run as the caller through ordinary +-- ACL-checked DML, so a grant-only approach can't cover the trigger +-- toggle: confirmed directly, a role with full DML and even the +-- TRIGGER privilege on both tables still gets "must be owner of table +-- layer" from RenameTopoGeometryColumn(). Reassigning ownership of +-- exactly these two tables is the only way to cover all of that. +-- Scoped to exactly these two tables rather than the whole schema, so +-- it doesn't also hand write access to other gated extensions' +-- catalogs that are meant to stay admin-only. +-- +-- Reassigned to pg_database_owner rather than a hardcoded role name, +-- so this keeps working if the database is later reassigned to a +-- different owner. See +-- https://www.postgresql.org/docs/current/predefined-roles.html. +ALTER TABLE topology.topology OWNER TO pg_database_owner; +ALTER TABLE topology.layer OWNER TO pg_database_owner; +GRANT USAGE ON SEQUENCE topology.topology_id_seq TO pg_database_owner; diff --git a/extension-custom-scripts/vchord_bm25/after-create.sql b/extension-custom-scripts/vchord_bm25/after-create.sql new file mode 100644 index 0000000..95fa3bd --- /dev/null +++ b/extension-custom-scripts/vchord_bm25/after-create.sql @@ -0,0 +1,10 @@ +-- USAGE on bm25_catalog is needed to declare a column of its +-- bm25vector type and call its functions (search_bm25query, +-- to_bm25query); Postgres already grants EXECUTE on new functions to +-- PUBLIC by default, so no separate function grant is needed. The +-- schema holds only the type and its support functions, no tables. +-- +-- Also granted to PUBLIC: pg_database_owner's own grant carries no +-- GRANT OPTION, so there is no way to pass it on to another role +-- afterward, and the attempt is a silent no-op, not an error. +GRANT USAGE ON SCHEMA bm25_catalog TO pg_database_owner, PUBLIC; diff --git a/scripts/build_pgedge_images.py b/scripts/build_pgedge_images.py index 2a7a2f9..161adb8 100755 --- a/scripts/build_pgedge_images.py +++ b/scripts/build_pgedge_images.py @@ -163,7 +163,7 @@ def make_all_flavor_images( *make_all_flavor_images( postgres_version="16.15", spock_version="5.0.11", - epoch=2, + epoch=3, is_latest_for_pg_major=True, is_latest_for_spock_major=True, ), @@ -171,7 +171,7 @@ def make_all_flavor_images( *make_all_flavor_images( postgres_version="17.11", spock_version="5.0.11", - epoch=2, + epoch=3, is_latest_for_pg_major=True, is_latest_for_spock_major=True, ), @@ -179,7 +179,7 @@ def make_all_flavor_images( *make_all_flavor_images( postgres_version="18.6", spock_version="5.0.11", - epoch=2, + epoch=3, is_latest_for_pg_major=True, is_latest_for_spock_major=True, ), @@ -187,7 +187,7 @@ def make_all_flavor_images( *make_all_flavor_images( postgres_version="16.15", spock_version="6.0.0-beta1", - epoch=2, + epoch=3, is_latest_for_pg_major=True, is_latest_for_spock_major=True, ), @@ -195,7 +195,7 @@ def make_all_flavor_images( *make_all_flavor_images( postgres_version="17.11", spock_version="6.0.0-beta1", - epoch=2, + epoch=3, is_latest_for_pg_major=True, is_latest_for_spock_major=True, ), @@ -203,7 +203,7 @@ def make_all_flavor_images( *make_all_flavor_images( postgres_version="18.6", spock_version="6.0.0-beta1", - epoch=2, + epoch=3, is_latest_for_pg_major=True, is_latest_for_spock_major=True, ), diff --git a/tests/main.go b/tests/main.go index 7166e8f..ac92538 100644 --- a/tests/main.go +++ b/tests/main.go @@ -409,7 +409,7 @@ func (r *TestRunner) Start() error { // Note: We only include extensions that are guaranteed to be in all images sharedLibs := "spock,snowflake" if r.flavor == "standard" { - sharedLibs = "spock,snowflake,pgaudit,supautils" + sharedLibs = "spock,snowflake,pgaudit,supautils,pg_cron,pg_tokenizer" } // Build postgres command with required configuration @@ -423,6 +423,11 @@ func (r *TestRunner) Start() error { "-c", "max_wal_senders=10", "-c", "snowflake.node=1", } + if r.flavor == "standard" { + // pg_cron only ever installs into the one database this names, + // and refuses CREATE EXTENSION anywhere else. + cmd = append(cmd, "-c", "cron.database_name=testdb", "-c", "cron.use_background_workers=on") + } resp, err := r.cli.ContainerCreate(r.ctx, &container.Config{ Image: r.image, @@ -669,7 +674,7 @@ func buildTestSuite(spockMajor string) []Test { // getSpockVersionTests asserts that the spock extension installed in the image // matches the major version advertised by the image tag. This distinguishes, -// for example, a spock6 image from a spock5 image — a mismatch would otherwise +// for example, a spock6 image from a spock5 image, a mismatch would otherwise // pass every other test unnoticed. Returns no tests when the expected major // version could not be derived from the image tag. func getSpockVersionTests(spockMajor string) []Test { @@ -789,7 +794,8 @@ func getCommonExtensionTests() []Test { func getStandardOnlyTests() []Test { tests := append(getSystemStatsAndVectorTests(), getPostGISAuditBackrestTests()...) - return append(tests, getSupautilsTests()...) + tests = append(tests, getSupautilsTests()...) + return append(tests, getExtensionCustomScriptsTests()...) } func getSupautilsTests() []Test { @@ -921,3 +927,457 @@ func expectSuccess(exitCode int, output string) error { } return nil } + +// expectFailureContaining returns an ExpectedOutput func for a command that +// must fail (a non-zero exit from psql -c means the statement errored), with +// the error text containing want. Used for every negative case below: a +// plain non-zero exit code alone would also pass for the wrong reason (a +// typo'd role name, a connection failure), so the actual error text is +// checked too. +func expectFailureContaining(want string) func(exitCode int, output string) error { + return func(exitCode int, output string) error { + if exitCode == 0 { + return fmt.Errorf("expected failure, got success: %s", output) + } + if !strings.Contains(output, want) { + return fmt.Errorf("expected output containing %q, got: %s", want, output) + } + return nil + } +} + +// expectOutputContaining returns an ExpectedOutput func for a command that +// must succeed and whose output must contain want. Used where the exit code +// alone proves nothing, because the query returns a value that is the actual +// assertion rather than erroring when it fails. +func expectOutputContaining(want string) func(exitCode int, output string) error { + return func(exitCode int, output string) error { + if exitCode != 0 { + return fmt.Errorf("unexpected exit code: %d, output: %s", exitCode, output) + } + if !strings.Contains(output, want) { + return fmt.Errorf("expected output containing %q, got: %s", want, output) + } + return nil + } +} + +// getExtensionCustomScriptsTests exercises supautils' gate and the +// extension-custom-scripts this repo ships, not just that the library +// loads: a non-superuser role installing an allowlisted extension through +// the gate, the same role refused a non-allowlisted one, and each +// extension's after-create.sql granting the database's own owner exactly +// the access it documents, in whichever schema the extension actually +// landed in, not a hardcoded one. +func getExtensionCustomScriptsTests() []Test { + return []Test{ + { + // Made testdb's actual owner, not just given CREATE on it: the + // scripts under test grant to pg_database_owner, a predefined + // role whose membership tracks whoever currently owns the + // database, and testdb starts out owned by postgres, a + // superuser that bypasses every ACL check regardless of what + // gets granted. Without this, checking pg_database_owner's + // access would really be checking postgres's, which proves + // nothing. + Name: "create the non-superuser role the gate tests connect as", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"CREATE ROLE gate_test_role LOGIN NOSUPERUSER; ALTER DATABASE testdb OWNER TO gate_test_role; ALTER ROLE gate_test_role SET session_preload_libraries = 'supautils';\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "configure supautils.privileged_role", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"ALTER SYSTEM SET supautils.privileged_role = 'gate_test_role';\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "configure supautils.superuser", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"ALTER SYSTEM SET supautils.superuser = 'postgres';\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "configure supautils.privileged_extensions", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"ALTER SYSTEM SET supautils.privileged_extensions = 'address_standardizer, address_standardizer_data_us, pg_cron, pg_tokenizer, vchord_bm25, postgis, postgis_tiger_geocoder, postgis_topology';\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "configure supautils.extension_custom_scripts_path", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"ALTER SYSTEM SET supautils.extension_custom_scripts_path = '/etc/pgedge/extension-custom-scripts';\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "reload for the new supautils settings to take effect", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"SELECT pg_reload_conf();\"", + ExpectedOutput: expectSuccess, + }, + { + // dblink is untrusted and not on the allowlist configured above: + // the gate must refuse it for a non-superuser role the same way + // Postgres core would refuse any untrusted extension. + Name: "gate refuses a non-allowlisted extension", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION dblink;\"", + ExpectedOutput: expectFailureContaining("Must be superuser"), + }, + { + // address_standardizer is a dependency address_standardizer_data_us + // needs installed first; both are on the allowlist configured above. + Name: "gate allows an allowlisted extension", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION address_standardizer; CREATE EXTENSION address_standardizer_data_us;\"", + ExpectedOutput: expectSuccess, + }, + { + // Confirms after-create.sql actually ran and granted access, + // not just that the extension installed: gate_test_role has no + // grant of its own on these tables, only what the script gave + // pg_database_owner, which gate_test_role belongs to by owning + // testdb (see the role-creation step above). Runs the real + // query, not a has_table_privilege check: that check only + // covers the table-level grant, missing a separate, real bug + // this exact test caught once already, a table grant with no + // matching schema USAGE, which fails at query time despite + // has_table_privilege reporting true. + Name: "address_standardizer_data_us after-create.sql granted access", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT count(*) FROM us_lex;\"", + ExpectedOutput: expectSuccess, + }, + { + // Reproduces the case the schema lookup in after-create.sql + // exists for: an explicit SCHEMA clause lands the tables + // somewhere other than public, and the grant must still land + // on the actual schema, not a hardcoded one. + Name: "address_standardizer_data_us after-create.sql follows an explicit SCHEMA clause", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"CREATE SCHEMA relocated_gis; DROP EXTENSION address_standardizer_data_us; DROP EXTENSION address_standardizer;\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "reinstall address_standardizer_data_us into the relocated schema", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION address_standardizer SCHEMA relocated_gis; CREATE EXTENSION address_standardizer_data_us SCHEMA relocated_gis;\"", + ExpectedOutput: expectSuccess, + }, + { + // Real query again, for the same reason as the default-schema + // case above, and specifically the one where a missing schema + // USAGE grant would actually surface: public grants USAGE to + // PUBLIC by default, so the default-schema case would have + // passed even without it, this relocated schema has no such + // default and only passes if the script's own USAGE grant + // worked. + Name: "address_standardizer_data_us after-create.sql found the relocated schema", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT count(*) FROM relocated_gis.us_lex;\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "gate installs pg_cron", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION pg_cron;\"", + ExpectedOutput: expectSuccess, + }, + { + // pg_cron's after-create.sql grants USAGE + SELECT only, no + // ownership: schedule() writes to cron.job through pg_cron's + // own internal code, not a caller-privileged INSERT, so + // SELECT is enough for the database's owner to schedule its + // own jobs directly. + Name: "pg_cron after-create.sql lets the owner schedule its own job", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT cron.schedule('probe', '* * * * *', 'SELECT 1');\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "pg_cron after-create.sql lets the owner list its own job", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT jobname FROM cron.job WHERE jobname = 'probe';\"", + ExpectedOutput: func(exitCode int, output string) error { + if exitCode != 0 { + return fmt.Errorf("unexpected exit code: %d", exitCode) + } + if strings.TrimSpace(output) != "probe" { + return fmt.Errorf("expected to see the scheduled job, got: %s", output) + } + return nil + }, + }, + { + Name: "pg_cron after-create.sql lets the owner unschedule its own job", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT cron.unschedule('probe');\"", + ExpectedOutput: func(exitCode int, output string) error { + if exitCode != 0 { + return fmt.Errorf("unexpected exit code: %d", exitCode) + } + if strings.TrimSpace(output) != "t" { + return fmt.Errorf("expected the job to be unscheduled, got: %s", output) + } + return nil + }, + }, + { + // The thing SELECT-only deliberately does not allow: a raw + // write against cron.job. No ownership, no write path. + Name: "pg_cron after-create.sql refuses a raw write to cron.job", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"INSERT INTO cron.job (schedule, command, nodename, nodeport, database, username) VALUES ('* * * * *', 'SELECT 1', 'localhost', 5432, 'testdb', 'postgres');\"", + ExpectedOutput: expectFailureContaining("permission denied for table job"), + }, + { + Name: "gate installs pg_tokenizer", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION pg_tokenizer;\"", + ExpectedOutput: expectSuccess, + }, + { + // Real query against a real table in tokenizer_catalog, the + // same reasoning as address_standardizer_data_us above: a + // table grant with no matching schema USAGE fails here even + // though has_table_privilege would report true. + Name: "pg_tokenizer after-create.sql granted access to tokenizer_catalog", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT count(*) FROM tokenizer_catalog.tokenizer;\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "gate installs vchord_bm25", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION vchord_bm25;\"", + ExpectedOutput: expectSuccess, + }, + { + // bm25_catalog holds only the bm25vector type and its support + // functions, no tables: declaring a column of that type is + // the real thing the database's owner needs USAGE on the + // schema for. + Name: "vchord_bm25 after-create.sql granted access to bm25_catalog", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE TABLE bm25_probe(id int, v bm25_catalog.bm25vector);\"", + ExpectedOutput: expectSuccess, + }, + { + // The earlier, unrelated common extension test already + // installed postgis as postgres directly, before the gate + // was configured. Drop it so the gate genuinely installs it + // below, the same reset pattern used for + // address_standardizer_data_us above. + Name: "reset: drop postgis installed by the earlier common test", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"DROP EXTENSION IF EXISTS postgis CASCADE;\"", + ExpectedOutput: expectSuccess, + }, + { + // postgis_tiger_geocoder and postgis_topology both depend on + // postgis; installed here as the gated role, the same as any + // other allowlisted extension. + Name: "gate installs postgis", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION postgis;\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "gate installs postgis_tiger_geocoder", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION postgis_tiger_geocoder CASCADE;\"", + ExpectedOutput: expectSuccess, + }, + { + // Real query against a real table in tiger, created at + // CREATE EXTENSION time, the same reasoning as + // address_standardizer_data_us and pg_tokenizer above. + Name: "postgis_tiger_geocoder after-create.sql granted access to tiger", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT count(*) FROM tiger.county;\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "gate installs postgis_topology", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE EXTENSION postgis_topology;\"", + ExpectedOutput: expectSuccess, + }, + { + // CreateTopology() INSERTs into topology.topology and + // topology.layer, which needs real ownership of both tables, + // not just a grant: unlike pg_cron, postgis_topology's own + // functions run as the caller through ordinary ACL-checked + // DML, and RenameTopoGeometryColumn() additionally runs + // ALTER TABLE ... DISABLE/ENABLE TRIGGER on topology.layer, + // which only an owner or superuser can do. + Name: "postgis_topology after-create.sql lets the owner create a topology", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT topology.CreateTopology('probe_topo', 4326);\"", + ExpectedOutput: expectSuccess, + }, + { + // AddTopoGeometryColumn only needs INSERT, which a plain + // grant already covers, so this alone would pass even + // without ownership. Included for lifecycle completeness, + // the real proof is the rename step below. + Name: "postgis_topology after-create.sql lets the owner register a layer", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"CREATE TABLE probe_feat(id serial primary key); SELECT topology.AddTopoGeometryColumn('probe_topo', 'public', 'probe_feat', 'g', 'POLYGON');\"", + ExpectedOutput: expectSuccess, + }, + { + // This is the one call in the whole lifecycle that a plain + // grant cannot satisfy: RenameTopoGeometryColumn() runs + // ALTER TABLE topology.layer DISABLE/ENABLE TRIGGER, which + // needs real ownership. Confirmed directly: a role with + // full DML and even the TRIGGER privilege on both tables + // still gets "must be owner of table layer" here, so this + // is the test that actually proves the ownership handoff + // is doing something, not just that CreateTopology's INSERT + // happens to work. + Name: "postgis_topology after-create.sql lets the owner rename a layer column", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT topology.RenameTopoGeometryColumn('probe_feat', 'g', 'g2');\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "postgis_topology after-create.sql lets the owner drop a topology", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT topology.DropTopology('probe_topo');\"", + ExpectedOutput: expectSuccess, + }, + { + // A role the database's owner creates itself, not the + // owner and not a member of pg_database_owner: none of the + // grants above reach it through membership at all, so this + // is what actually proves the PUBLIC grant, not just that + // pg_database_owner has access. + Name: "create a third-party role the owner does not control access through", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"CREATE ROLE reporting_role LOGIN NOSUPERUSER;\"", + ExpectedOutput: expectSuccess, + }, + { + // pg_database_owner's own grant carries no GRANT OPTION, + // so the owner has no way to pass this on by hand either; + // confirms that silent no-op rather than assuming it. + Name: "owner re-granting schema access by hand is a silent no-op", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"GRANT USAGE ON SCHEMA tiger TO reporting_role;\" 2>&1", + ExpectedOutput: func(exitCode int, output string) error { + if exitCode != 0 { + return fmt.Errorf("unexpected exit code: %d", exitCode) + } + if !strings.Contains(output, "no privileges were granted") { + return fmt.Errorf("expected a no-op warning, got: %s", output) + } + return nil + }, + }, + { + // Reads relocated_gis.us_lex, not the bare table name: an + // earlier test in this suite already relocated + // address_standardizer_data_us there via an explicit + // SCHEMA clause. + Name: "third-party role reaches address_standardizer_data_us via PUBLIC", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT count(*) FROM relocated_gis.us_lex;\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "third-party role reaches postgis_tiger_geocoder via PUBLIC", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT normalize_address('1 Devonshire Pl, Boston, MA 02109');\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "third-party role reaches vchord_bm25/pg_tokenizer via PUBLIC", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT pg_typeof('{1:1}'::bm25_catalog.bm25vector); SELECT count(*) FROM tokenizer_catalog.tokenizer;\"", + ExpectedOutput: expectSuccess, + }, + { + // The write side stays pg_database_owner-only regardless + // of the PUBLIC read grant above. + Name: "third-party role still refused a tokenizer_catalog write", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"INSERT INTO tokenizer_catalog.tokenizer(name) VALUES ('probe');\"", + ExpectedOutput: expectFailureContaining("permission denied for table tokenizer"), + }, + { + // Schema USAGE makes every function in tokenizer_catalog + // resolvable, and Postgres grants EXECUTE on new functions + // to PUBLIC by default, so without this REVOKE a + // third-party role could reach config-parsing/model-loading + // functions that were never meant to be public, none of + // them SECURITY DEFINER, but still real work running before + // any table-ACL check fires. Confirmed cleanly refused at + // the function call itself now, not at some later step. + Name: "third-party role refused the tokenizer_catalog config-management functions", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT tokenizer_catalog.create_tokenizer('probe', 'x');\"", + ExpectedOutput: expectFailureContaining("permission denied for function create_tokenizer"), + }, + { + // The three functions the read-only use case actually + // needs stay PUBLIC-executable: tokenize() and + // apply_text_analyzer() to process text against an + // existing configuration, list_preload_models() to see + // what's available. + Name: "third-party role keeps the read-only tokenizer_catalog functions", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT tokenizer_catalog.list_preload_models();\"", + ExpectedOutput: expectSuccess, + }, + { + // pg_cron is deliberately not part of this fix: cron.job is + // already scoped per username by its own row-level security + // policy, a PUBLIC grant here would not change what a + // third-party role can see or do with it. + Name: "third-party role still has no path into pg_cron", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT cron.schedule('probe', '* * * * *', 'SELECT 1');\"", + ExpectedOutput: expectFailureContaining("permission denied for schema cron"), + }, + { + // Stands in for a function an extension upgrade adds after + // after-create.sql has already run. The REVOKE in that script + // only reaches functions that exist when it runs, and + // ALTER DEFAULT PRIVILEGES cannot cover the gap, since + // Postgres seeds every new function with EXECUTE to PUBLIC + // and default privileges only add to that, never remove it. + // Created as postgres because tokenizer_catalog is owned by + // postgres, which is also who runs a gated install. + Name: "a function added to tokenizer_catalog after install", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"CREATE FUNCTION tokenizer_catalog.upgrade_probe(text) RETURNS text LANGUAGE sql AS 'SELECT \\$1';\"", + ExpectedOutput: expectSuccess, + }, + { + Name: "third-party role refused a function added after install", + StandardOnly: true, + Cmd: "psql -U reporting_role -d testdb -t -A -c \"SELECT tokenizer_catalog.upgrade_probe('x');\"", + ExpectedOutput: expectFailureContaining("permission denied for function upgrade_probe"), + }, + { + // The lockdown re-grants pg_database_owner explicitly, so the + // database's owner keeps a function an upgrade adds rather + // than losing it along with PUBLIC. + Name: "database owner keeps a function added after install", + StandardOnly: true, + Cmd: "psql -U gate_test_role -d testdb -t -A -c \"SELECT tokenizer_catalog.upgrade_probe('x');\"", + ExpectedOutput: expectSuccess, + }, + { + // The event trigger is database-wide, so it has to leave + // functions outside tokenizer_catalog on Postgres' own + // default. Without the schema check it would silently strip + // EXECUTE from every function anyone creates. + Name: "a function outside tokenizer_catalog keeps the PUBLIC default", + StandardOnly: true, + Cmd: "psql -U postgres -d testdb -t -A -c \"CREATE SCHEMA untouched; CREATE FUNCTION untouched.probe(text) RETURNS text LANGUAGE sql AS 'SELECT \\$1'; SELECT has_function_privilege('public', 'untouched.probe(text)', 'EXECUTE');\"", + ExpectedOutput: expectOutputContaining("t"), + }, + } +}