-- Complain if script is sourced in `psql`, rather than via `CREATE EXTENSION`. \echo Use "CREATE EXTENSION pg_role_fkey_trigger_functions" to load this file. \quit /** * CHANGELOG.md: * * - It happens often that this extension's designer wants to repeat himself * rather than keep all his code as DRY as possible. To make almost literal * repetitions across and within source files easier to manage, Rowan * created a new script: `wet` * * `wet` too, like many helper scripts, will be copied between projects rather * than introduced as some external dependency. This whole-file duplication, * however, will probably be managed by another, as of yet non-existent, tool: * `dry`. */ /** * CHANGELOG.md: * * - The `pg_role_fkey_trigger_functions__trusted_tables()` function is no * longer used by its brethern functions— * * 1. `pg_role_fkey_trigger_functions__trust_table()` and * 2. `pg_role_fkey_trigger_functions__trust_tables()`. * ~ * The reason is threefold: * * 1. `pg_role_fkey_trigger_functions__trusted_tables()` was meant to _only_ * return the `regclass`es of the relations that currently exist, whereas * its `*_trust_table()` and `*_trust_tables()` counterparts should retain * trusted tables, even if they do _not_ currently exist. * * 2. The fiddling with the `search_path` within the latter two functions was * still buggy and hacky anyway (though this could have been solved * differently, for example, by using the `pg_catalog` instead of the * `regclass::text` cast). * * 3. A fourth function could have been introduced, used by all 3 aforementioned * functions, but inter-function dependencies are a bit annoying extension * design anyway, because it makes it more difficult for users/developers * to cherry-pick parts of an extension. */ create or replace function pg_role_fkey_trigger_functions__trusted_tables( "role$" regrole = current_user::regrole ,"db$" text = current_database() ,"db_not_role_specific$" bool = true ,"db_and_role_specific$" bool = true ,"role_not_db_specific$" bool = true ) returns regclass[] stable leakproof parallel safe return ( select coalesce(array_agg(to_regclass(qualified_table) order by qualified_table), '{}'::regclass[]) from ( -- select distinct qualified_table from pg_catalog.pg_db_role_setting left outer join pg_catalog.pg_database on pg_database.oid = pg_db_role_setting.setdatabase cross join lateral unnest(pg_db_role_setting.setconfig) as expanded_settings(raw_setting) cross join lateral cast(regexp_replace(expanded_settings.raw_setting, E'^[^=]+=', '') as text[]) as a cross join lateral unnest(a) as qualified_table where ( ( -- "db_not_role_specific$" -- and pg_database.datname = "db$" and pg_db_role_setting.setrole = 0::oid ) or ( -- "db_and_role_specific$" -- and pg_database.datname = "db$" and pg_db_role_setting.setrole = "role$" ) or ( -- "role_not_db_specific$" -- and pg_db_role_setting.setdatabase = 0::oid and pg_db_role_setting.setrole = "role$" ) ) and expanded_settings.raw_setting like 'pg_role_fkey_trigger_functions.trusted_tables=%' -- ) as t where to_regclass(qualified_table) is not null ); /** * CHANGELOG.md: * * - The `comment on function pg_role_fkey_trigger_functions__trusted_tables()` * was written for a signature that never existed in a released version—i.e., * the argument types had already been changed into `(regrole, rext, bool, * bool, bool)` when the function was introduced in version 1.0.0 of the * extension). This comment (and hence the `README.md`) is now up to date. */ comment on function pg_role_fkey_trigger_functions__trusted_tables(regrole, text, bool, bool, bool) is $md$Returns the array of relations (of type `regclass[]`) that are trusted by the `SECURITY DEFINER` trigger functions. This function has five arguments, all of them optional: | Arg. | Name | Type | Default value | Description | | ----- | ------------------------ | ---------- | -------------------------| ---------------------------------------------------------- | | `$1` | `role$` | `regrole` | `current_user::regrole` | A role whose role-specific settings will be included. | | `$2` | `db$` | `text` ` | `current_database()` | The database to look up settings for. | | `$3` | `db_not_role_specific$` | `boolean` | `true` | Include DB-level settings not bound to a role. | | `$4` | `db_and_role_specific$` | `boolean` | `true` | Include settings which are specific to the role _and_ DB. | | `$5` | `role_not_db_specific$` | `boolean` | `true` | Include cluster-wide role settings. | See the [_Secure `pg_role_fkey_trigger_functions` usage_] section for details as to how and why this list is maintained. [_Secure `pg_role_fkey_trigger_functions` usage_]: #secure-pg_role_fkey_trigger_functions-usage $md$; /** * CHANGELOG.md: * * - The `pg_role_fkey_trigger_functions__trust_table()` function was: * * + freed from its `pg_role_fkey_trigger_functions__trusted_tables()` * dependency (as explicated more extensively above); * * + fixed to always store fully qualified relation names, also when the * `current_schema()` is identical to the `$extension_schema`; and * * + fixed to use the `to_regclass()` rather than the `text::regclass`, so * that the function doesn't crash when any of the trusted tables * can not be resolved into an `oid`/`regclass`. */ create or replace function pg_role_fkey_trigger_functions__trust_table( "table$" regclass ,"role$" regrole = null ,"db$" text = current_database() ) returns regclass[] volatile leakproof set pg_role_fkey_trigger_functions.search_path to pg_catalog language plpgsql as $$ declare _qualified_relation_names text[]; begin if "role$" is null and "db$" is null then raise data_exception using message = '"role$" and "db$" arguments to this function cannot both be `NULL`.' ; end if; _qualified_relation_names := array( select quote_ident(pg_namespace.nspname) || '.' || quote_ident(pg_class.relname) as qualified_table from pg_catalog.pg_class inner join pg_catalog.pg_namespace on pg_namespace.oid = pg_class.relnamespace where pg_class.oid = "table$" union -- select distinct qualified_table from pg_catalog.pg_db_role_setting left outer join pg_catalog.pg_database on pg_database.oid = pg_db_role_setting.setdatabase cross join lateral unnest(pg_db_role_setting.setconfig) as expanded_settings(raw_setting) cross join lateral cast(regexp_replace(expanded_settings.raw_setting, E'^[^=]+=', '') as text[]) as a cross join lateral unnest(a) as qualified_table where ( ( -- -- = "db_not_role_specific$" in `pg_role_fkey_trigger_functions__trusted_tables() ("db$" is not null and "role$" is null) -- and pg_database.datname = "db$" and pg_db_role_setting.setrole = 0::oid ) or ( -- -- = "db_and_role_specific$" in `pg_role_fkey_trigger_functions__trusted_tables() ("db$" is not null and "role$" is not null) -- and pg_database.datname = "db$" and pg_db_role_setting.setrole = "role$" ) or ( -- -- = "role_not_db_specific$" in `pg_role_fkey_trigger_functions__trusted_tables() ("db$" is null and "role$" is not null) -- and pg_db_role_setting.setdatabase = 0::oid and pg_db_role_setting.setrole = "role$" ) ) and expanded_settings.raw_setting like 'pg_role_fkey_trigger_functions.trusted_tables=%' -- ); execute format( 'ALTER %s SET pg_role_fkey_trigger_functions.trusted_tables TO %L' ,coalesce('ROLE ' || "role$"::text, '') || case when "role$" is not null and "db$" is not null then ' IN ' else '' end || coalesce('DATABASE ' || quote_ident("db$"), '') ,_qualified_relation_names::text ); return ( select array_agg(to_regclass(qname) order by qname) from unnest(_qualified_relation_names) as qname where to_regclass(qname) is not null ); end; $$; /** * CHANGELOG.md: * * - Equally, the `pg_role_fkey_trigger_functions__trust_tables()` function was * also: * * + freed from its `pg_role_fkey_trigger_functions__trusted_tables()` * dependency (as explicated more extensively above); * * + fixed to always store fully qualified relation names, also when the * `current_schema()` is identical to the `$extension_schema`; and * * + fixed to use the `to_regclass()` rather than the `text::regclass`, so * that the function doesn't crash when any of the trusted tables * can not be resolved into an `oid`/`regclass`. */ create or replace function pg_role_fkey_trigger_functions__trust_tables( "tables$" regclass[] ,"role$" regrole = null ,"db$" text = current_database() ) returns regclass[] set pg_role_fkey_trigger_functions.search_path to pg_catalog volatile leakproof language plpgsql as $$ declare _qualified_relation_names text[]; begin if "role$" is null and "db$" is null then raise data_exception using message = '"role$" and "db$" arguments to this function cannot both be `NULL`.' ; end if; _qualified_relation_names := array( select quote_ident(pg_namespace.nspname) || '.' || quote_ident(pg_class.relname) as qualified_table from pg_catalog.pg_class inner join pg_catalog.pg_namespace on pg_namespace.oid = pg_class.relnamespace where pg_class.oid = any("tables$") union -- select distinct qualified_table from pg_catalog.pg_db_role_setting left outer join pg_catalog.pg_database on pg_database.oid = pg_db_role_setting.setdatabase cross join lateral unnest(pg_db_role_setting.setconfig) as expanded_settings(raw_setting) cross join lateral cast(regexp_replace(expanded_settings.raw_setting, E'^[^=]+=', '') as text[]) as a cross join lateral unnest(a) as qualified_table where ( ( -- -- = "db_not_role_specific$" in `pg_role_fkey_trigger_functions__trusted_tables() ("db$" is not null and "role$" is null) -- and pg_database.datname = "db$" and pg_db_role_setting.setrole = 0::oid ) or ( -- -- = "db_and_role_specific$" in `pg_role_fkey_trigger_functions__trusted_tables() ("db$" is not null and "role$" is not null) -- and pg_database.datname = "db$" and pg_db_role_setting.setrole = "role$" ) or ( -- -- = "role_not_db_specific$" in `pg_role_fkey_trigger_functions__trusted_tables() ("db$" is null and "role$" is not null) -- and pg_db_role_setting.setdatabase = 0::oid and pg_db_role_setting.setrole = "role$" ) ) and expanded_settings.raw_setting like 'pg_role_fkey_trigger_functions.trusted_tables=%' -- ); execute format( 'ALTER %s SET pg_role_fkey_trigger_functions.trusted_tables TO %L' ,coalesce('ROLE ' || "role$"::text, '') || case when "role$" is not null and "db$" is not null then ' IN ' else '' end || coalesce('DATABASE ' || quote_ident("db$"), '') ,_qualified_relation_names::text ); return ( select array_agg(to_regclass(qname) order by qname) from unnest(_qualified_relation_names) as qname where to_regclass(qname) is not null ); end; $$; /** * CHANGELOG.md: * * - The `test__pg_role_fkey_trigger_functions()` procedure now more explicitly * test the desired behaviours of the functions that were improved in this * release: * * + `pg_role_fkey_trigger_functions__trusted_tables(regrole, text, bool, bool, bool)`, * + `pg_role_fkey_trigger_functions__trust_table(regclass, regrole, test)`, and * + `pg_role_fkey_trigger_functions__trust_tables(regclass[], regrole, text)`. */ create or replace procedure test__pg_role_fkey_trigger_functions() set pg_role_fkey_trigger_functions.search_path_template = '"$extension_schema", pg_catalog' set plpgsql.check_asserts to true set pg_readme.include_this_routine_definition to true language plpgsql as $$ declare _inserted_account_owner_role name; _updated_account_owner_role name; begin create role test__customer_group; create role test__account_manager; create role test__new_account_manager; create role test__youngest_intern; create role test__trusting_role; create table test__customer ( account_owner_role name primary key default 'user_' || gen_random_uuid()::text, account_manager_role name not null ); create constraint trigger tg1_account_manager_role_fkey after insert or update on test__customer for each row execute function enforce_fkey_to_db_role('account_manager_role'); create trigger tg2_account_owner_role_fkey after insert or update or delete on test__customer for each row execute function maintain_referenced_role( 'account_owner_role', 'IN ROLE test__customer_group' ); create trigger tg3_grant_owner_impersonation_to_account_manager after insert on test__customer for each row execute function grant_role_in_column1_to_role_in_column2( 'account_owner_role', 'account_manager_role' ); create trigger tg4_revoke_owner_impersonation_from_old_account_manager after update on test__customer for each row when (NEW.account_manager_role is distinct from OLD.account_manager_role) execute function revoke_role_in_column1_from_role_in_column2( 'account_owner_role', 'account_manager_role' ); create trigger tg5_grant_owner_impersonation_to_new_account_manager after update on test__customer for each row when (NEW.account_manager_role is distinct from OLD.account_manager_role) execute function grant_role_in_column1_to_role_in_column2( 'account_owner_role', 'account_manager_role' ); assert pg_role_fkey_trigger_functions__trusted_tables() = '{}'::regclass[]; assert pg_role_fkey_trigger_functions__trusted_tables('test__trusting_role') = '{}'::regclass[]; perform pg_role_fkey_trigger_functions__trust_table('test__customer', 'test__trusting_role'); assert pg_role_fkey_trigger_functions__trusted_tables('test__trusting_role') = array[ 'test__customer'::regclass ]::regclass[], pg_role_fkey_trigger_functions__trusted_tables('test__trusting_role') ; <> declare begin insert into test__customer (account_owner_role, account_manager_role) values (default, 'test__account_manager'::regrole) returning account_owner_role into _inserted_account_owner_role ; raise assert_failure using message = 'The trigger function should have raised `insufficient_privilege`.'; exception when insufficient_privilege then end; perform pg_role_fkey_trigger_functions__trust_table('test__customer'); create table test__trusted_table_2 (dummy_col int); create table test__trusted_table_3 (dummy_col int); assert pg_role_fkey_trigger_functions__trust_tables(array[ 'test__trusted_table_2'::regclass ,'test__trusted_table_3'::regclass ]) = array[ 'test__customer', 'test__trusted_table_2', 'test__trusted_table_3' ]::regclass[]; assert pg_role_fkey_trigger_functions__trusted_tables() = array[ 'test__customer', 'test__trusted_table_2', 'test__trusted_table_3' ]::regclass[], pg_role_fkey_trigger_functions__trusted_tables(); drop table test__trusted_table_2; assert pg_role_fkey_trigger_functions__trusted_tables() = array[ 'test__customer', 'test__trusted_table_3' ]::regclass[], pg_role_fkey_trigger_functions__trusted_tables(); drop table test__trusted_table_3; create table test__trusted_table_4 (dummy int); assert pg_role_fkey_trigger_functions__trust_table('test__trusted_table_4') = array[ 'test__customer', 'test__trusted_table_4' ]::regclass[]; create table test__trusted_table_3 (dummy_col int); assert pg_role_fkey_trigger_functions__trusted_tables('test__trusting_role') = array[ 'test__customer', 'test__trusted_table_3', 'test__trusted_table_4' ]::regclass[], format( 'The trusted table should have been remembered, got: %s' ,pg_role_fkey_trigger_functions__trusted_tables('test__trusting_role') ); <> declare _message_text text; _pg_exception_detail text; _nonexistent_role name := 'test__account_manager_that_doesnt_exist'; begin insert into test__customer values (default, _nonexistent_role); raise assert_failure using message = 'The trigger function should have gotten upset about the missing `ROLE`.'; exception when foreign_key_violation then get stacked diagnostics _message_text := message_text ,_pg_exception_detail := pg_exception_detail ; assert _message_text = format('Unknown database role: %I', _nonexistent_role); assert _pg_exception_detail = format( '`TRIGGER tg1_account_manager_role_fkey AFTER INSERT ON %I.test__customer FOR EACH ROW' ' EXECUTE FUNCTION enforce_fkey_to_db_role(%L)`' ,current_schema(), 'account_manager_role' ), format('Unexpected error detail: %s', _pg_exception_detail); end; <> declare _role constant name := 'test__preexisting_user'; begin create role test__preexisting_user; insert into test__customer values (_role, 'test__account_manager'::regrole); raise assert_failure using message = format( 'The trigger function should have gotten upset about the existing `%I` role.', _role ); exception when integrity_constraint_violation then assert sqlerrm = format('Role %I already exists.', _role), format('Unexpected `sqlerrm = %L`', sqlerrm) ; end insert_existing_role; insert into test__customer (account_owner_role, account_manager_role) values (default, 'test__account_manager'::regrole) returning account_owner_role into _inserted_account_owner_role ; assert exists (select from pg_roles where rolname = _inserted_account_owner_role), 'The role should have been created by the maintain_referenced_role() trigger function.'; assert pg_has_role(_inserted_account_owner_role, 'test__customer_group', 'USAGE'), 'The new role should have became a member of the "test__customer_group".'; assert pg_has_role('test__account_manager', _inserted_account_owner_role::regrole, 'USAGE'), format( 'The %s role should have gotten access to the new %s "account_owner_role" by action of the' ' grant_role_in_column1_to_role_in_column2() trigger function' ,'test__account_manager', _inserted_account_owner_role ); <> begin update test__customer set account_manager_role = 'test__invalid_account_manager'; raise assert_failure using message = 'The trigger function should have gotten upset about the missing `ROLE`.'; exception when foreign_key_violation then assert sqlerrm = 'Unknown database role: test__invalid_account_manager'; end; -- Dummy update, to check for rogue trigger behaviour update test__customer set account_manager_role = account_manager_role; _updated_account_owner_role := 'test__custom_user_name'; update test__customer set account_owner_role = _updated_account_owner_role; assert exists (select from pg_roles where rolname = _updated_account_owner_role); assert not exists (select from pg_roles where rolname = _inserted_account_owner_role); assert pg_has_role(_updated_account_owner_role, 'test__customer_group', 'USAGE'); assert pg_has_role('test__account_manager', _updated_account_owner_role, 'USAGE'); update test__customer set account_manager_role = 'test__new_account_manager'::regrole; assert not pg_has_role('test__account_manager', _updated_account_owner_role, 'USAGE'), 'The old account manager should have lost impersonation rights on this customer.'; assert pg_has_role('test__new_account_manager', _updated_account_owner_role, 'USAGE'), 'The new account manager should have gotten impersonation rights on this customer.'; delete from test__customer; assert not exists (select from pg_roles where rolname = _updated_account_owner_role); drop role test__customer_group; drop role test__account_manager; drop role test__new_account_manager; drop role test__trusting_role; raise transaction_rollback; exception when transaction_rollback then end; $$; /** * CHANGELOG.md: * * - The elements in the `pg_role_fkey_trigger_functions.search_path_template` * settings were in the wrong order for all the functions for which this * setting was set. This order has now been reversed, from `'pg_catalog, * "$extension_schema"'`, to `'"$extension_schema", pg_catalog'`. */ alter function grant_role_in_column1_to_role_in_column2() set pg_role_fkey_trigger_functions.search_path_template = '"$extension_schema", pg_catalog'; alter function revoke_role_in_column1_from_role_in_column2() set pg_role_fkey_trigger_functions.search_path_template = '"$extension_schema", pg_catalog'; alter function maintain_referenced_role() set pg_role_fkey_trigger_functions.search_path_template = '"$extension_schema", pg_catalog'; alter procedure test_dump_restore__maintain_referenced_role(text) set pg_role_fkey_trigger_functions.search_path_template = '"$extension_schema", pg_catalog'; call pg_role_fkey_trigger_functions__alter_routines_to_reset_search_paths();