-- Copyright (c) Microsoft Corporation. -- Licensed under the PostgreSQL License. -- Tests: delegated grant_usage / revoke_usage via with_grant parameter -- -- Scenario: PaaS operator (superuser) grants an admin role the ability to -- manage other roles' pg_durable access without superuser involvement. -- -- Test matrix: -- 1. Superuser grants admin_role with with_grant => true -- 2. admin_role can use pg_durable (start + complete a workflow) -- 3. Non-superuser admin CAN set with_grant => true (delegated admin can create sub-admins) -- 4. Non-superuser admin CANNOT set include_http => true without HTTP grant permission -- 5. admin_role can call df.grant_usage() to grant app_role access -- 5b. granted app_role reaches ordinary df.* functions via schema USAGE -- (no per-function EXECUTE grant) and is NOT granted df.http() -- 6. app_role can use pg_durable (start + complete a workflow) -- 7. app_role CANNOT call df.grant_usage() (no EXECUTE privilege) -- 8. admin_role can revoke app_role access -- 9. app_role can no longer use pg_durable -- 10. Self-revoke is harmless: PostgreSQL's REVOKE only removes grants the -- current role made, so admin_role cannot revoke its own postgres-granted -- access (no explicit guard needed) -- -- Note: cross-admin revoke is intentionally not tested here. These helper -- functions are SECURITY INVOKER, and PostgreSQL revoke semantics are scoped -- to the privileges the current role granted. That limitation is documented. -- -- Runs as postgres throughout (creates/drops roles, uses SET SESSION AUTHORIZATION) -- === Setup === DO $setup$ BEGIN PERFORM pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename IN ('dg_admin', 'dg_app', 'dg_delegate_target', 'dg_http_target') AND pid <> pg_backend_pid(); BEGIN DROP OWNED BY dg_admin; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP OWNED BY dg_app; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP OWNED BY dg_delegate_target; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP OWNED BY dg_http_target; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP ROLE dg_admin; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP ROLE dg_app; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP ROLE dg_delegate_target; EXCEPTION WHEN undefined_object THEN NULL; END; BEGIN DROP ROLE dg_http_target; EXCEPTION WHEN undefined_object THEN NULL; END; END $setup$; CREATE ROLE dg_admin LOGIN; CREATE ROLE dg_app LOGIN; CREATE ROLE dg_delegate_target LOGIN; CREATE ROLE dg_http_target LOGIN; -- The admin needs to create temp tables for df.start state tracking GRANT TEMPORARY ON DATABASE postgres TO dg_admin, dg_app, dg_delegate_target, dg_http_target; -- The admin needs CREATE on public for temp state tables used in tests GRANT USAGE, CREATE ON SCHEMA public TO dg_admin, dg_app, dg_delegate_target, dg_http_target; -- === Test 1: Superuser grants admin with with_grant => true === SELECT df.grant_usage('dg_admin', include_http => false, with_grant => true); -- Verify admin has EXECUTE on admin helpers DO $$ DECLARE can_grant BOOLEAN; can_revoke BOOLEAN; BEGIN SELECT has_function_privilege( 'dg_admin', 'df.grant_usage(text, boolean, boolean)', 'EXECUTE' ) INTO can_grant; SELECT has_function_privilege( 'dg_admin', 'df.revoke_usage(text)', 'EXECUTE' ) INTO can_revoke; IF NOT can_grant THEN RAISE EXCEPTION 'TEST 1 FAILED: dg_admin should have EXECUTE on df.grant_usage after with_grant => true'; END IF; IF NOT can_revoke THEN RAISE EXCEPTION 'TEST 1 FAILED: dg_admin should have EXECUTE on df.revoke_usage after with_grant => true'; END IF; RAISE NOTICE 'TEST 1 PASSED: admin role has EXECUTE on admin helpers after with_grant => true'; END $$; -- === Test 2: admin_role can use pg_durable === SET SESSION AUTHORIZATION dg_admin; CREATE TEMP TABLE _dg_admin_state (instance_id TEXT); INSERT INTO _dg_admin_state SELECT df.start(df.sql('SELECT 100 AS admin_result'), 'dg-admin-test'); RESET SESSION AUTHORIZATION; DO $$ DECLARE inst_id TEXT; status TEXT; BEGIN SELECT instance_id INTO inst_id FROM _dg_admin_state; SELECT df.await_instance(inst_id, 30) INTO status; IF status != 'completed' THEN RAISE EXCEPTION 'TEST 2 FAILED: admin workflow expected completed, got %', status; END IF; RAISE NOTICE 'TEST 2 PASSED: admin role can use pg_durable'; END $$; DROP TABLE _dg_admin_state; -- === Test 3: non-superuser admin CAN set with_grant => true === -- Since dg_admin was granted with_grant => true, it holds all privileges -- WITH GRANT OPTION and can delegate to other roles (including with_grant). SET SESSION AUTHORIZATION dg_admin; DO $$ BEGIN PERFORM df.grant_usage('dg_delegate_target', with_grant => true); RAISE NOTICE 'TEST 3 PASSED: delegated admin can set with_grant => true'; END $$; RESET SESSION AUTHORIZATION; DO $$ DECLARE can_grant BOOLEAN; BEGIN SELECT has_function_privilege( 'dg_delegate_target', 'df.grant_usage(text, boolean, boolean)', 'EXECUTE' ) INTO can_grant; IF NOT can_grant THEN RAISE EXCEPTION 'TEST 3 FAILED: dg_delegate_target should have EXECUTE on df.grant_usage after with_grant delegation'; END IF; RAISE NOTICE 'TEST 3 verification PASSED: delegated with_grant propagated admin helper access'; END $$; -- Clean up the delegation so it doesn't affect later tests SELECT df.revoke_usage('dg_delegate_target'); -- === Test 4: non-superuser admin CANNOT set include_http => true without HTTP grant permission === SET SESSION AUTHORIZATION dg_admin; DO $$ BEGIN BEGIN PERFORM df.grant_usage('dg_http_target', include_http => true); RAISE EXCEPTION 'SECURITY FAILURE: non-superuser admin was able to set include_http => true without HTTP grant permission'; EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'TEST 4 PASSED: non-superuser admin blocked from setting include_http => true without HTTP grant permission'; WHEN OTHERS THEN IF SQLERRM ILIKE '%permission denied%' THEN RAISE NOTICE 'TEST 4 PASSED: non-superuser admin blocked from setting include_http => true (%)', SQLERRM; ELSE RAISE EXCEPTION 'TEST 4 UNEXPECTED ERROR: %', SQLERRM; END IF; END; END $$; RESET SESSION AUTHORIZATION; DO $$ DECLARE can_http BOOLEAN; BEGIN SELECT has_function_privilege( 'dg_http_target', 'df.http(text, text, text, jsonb, integer)', 'EXECUTE' ) INTO can_http; IF can_http THEN RAISE EXCEPTION 'TEST 4 FAILED: dg_http_target should NOT have EXECUTE on df.http after failed include_http attempt'; END IF; RAISE NOTICE 'TEST 4 verification PASSED: failed include_http attempt did not grant HTTP access'; END $$; -- === Test 5: admin_role can grant access to app_role (without with_grant) === SET SESSION AUTHORIZATION dg_admin; DO $$ BEGIN PERFORM df.grant_usage('dg_app'); RAISE NOTICE 'TEST 5 PASSED: admin role successfully called df.grant_usage for app role'; END $$; RESET SESSION AUTHORIZATION; -- Verify app_role does NOT have EXECUTE on admin helpers DO $$ DECLARE can_grant BOOLEAN; can_revoke BOOLEAN; BEGIN SELECT has_function_privilege( 'dg_app', 'df.grant_usage(text, boolean, boolean)', 'EXECUTE' ) INTO can_grant; SELECT has_function_privilege( 'dg_app', 'df.revoke_usage(text)', 'EXECUTE' ) INTO can_revoke; IF can_grant THEN RAISE EXCEPTION 'TEST 5 FAILED: dg_app should NOT have EXECUTE on df.grant_usage'; END IF; IF can_revoke THEN RAISE EXCEPTION 'TEST 5 FAILED: dg_app should NOT have EXECUTE on df.revoke_usage'; END IF; RAISE NOTICE 'TEST 5 verification PASSED: app role does not have admin helper access'; END $$; -- === Test 5b: granted app_role reaches ordinary functions via schema USAGE, -- and a plain grant (no include_http) does NOT expose df.http() === -- This pins the access model after dropping the explicit func_sigs allowlist -- from df.grant_usage(): schema USAGE is the gate for ordinary df.* functions -- (which retain PostgreSQL's default PUBLIC EXECUTE), while df.http() stays -- opt-in because its PUBLIC EXECUTE is revoked at install time. DO $$ DECLARE has_usage BOOLEAN; can_start BOOLEAN; can_http BOOLEAN; BEGIN SELECT has_schema_privilege('dg_app', 'df', 'USAGE') INTO has_usage; SELECT has_function_privilege('dg_app', 'df.start(text, text, text, text)', 'EXECUTE') INTO can_start; SELECT has_function_privilege('dg_app', 'df.http(text, text, text, jsonb, integer)', 'EXECUTE') INTO can_http; IF NOT has_usage THEN RAISE EXCEPTION 'TEST 5b FAILED: dg_app should have USAGE on schema df after grant_usage'; END IF; IF NOT can_start THEN RAISE EXCEPTION 'TEST 5b FAILED: dg_app should reach df.start via schema USAGE + default PUBLIC EXECUTE'; END IF; IF can_http THEN RAISE EXCEPTION 'SECURITY FAILURE: dg_app granted without include_http should NOT have EXECUTE on df.http'; END IF; RAISE NOTICE 'TEST 5b PASSED: USAGE gates ordinary functions; df.http remains opt-in for a plain grant'; END $$; -- === Test 6: app_role can use pg_durable === SET SESSION AUTHORIZATION dg_app; CREATE TEMP TABLE _dg_app_state (instance_id TEXT); INSERT INTO _dg_app_state SELECT df.start(df.sql('SELECT 200 AS app_result'), 'dg-app-test'); RESET SESSION AUTHORIZATION; DO $$ DECLARE inst_id TEXT; status TEXT; BEGIN SELECT instance_id INTO inst_id FROM _dg_app_state; SELECT df.await_instance(inst_id, 30) INTO status; IF status != 'completed' THEN RAISE EXCEPTION 'TEST 6 FAILED: app workflow expected completed, got %', status; END IF; RAISE NOTICE 'TEST 6 PASSED: app role can use pg_durable via delegated grant'; END $$; DROP TABLE _dg_app_state; -- === Test 7: app_role CANNOT call df.grant_usage() === SET SESSION AUTHORIZATION dg_app; DO $$ BEGIN BEGIN PERFORM df.grant_usage('dg_admin'); RAISE EXCEPTION 'SECURITY FAILURE: app role was able to call df.grant_usage()'; EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'TEST 7 PASSED: app role blocked from calling df.grant_usage (insufficient privilege)'; END; END $$; RESET SESSION AUTHORIZATION; -- === Test 8: admin_role can revoke app_role access === SET SESSION AUTHORIZATION dg_admin; DO $$ DECLARE still_has_usage BOOLEAN; BEGIN PERFORM df.revoke_usage('dg_app'); RAISE NOTICE 'TEST 8 PASSED: admin role successfully called df.revoke_usage for app role'; -- Schema USAGE is the access gate; revoke_usage() must remove it. SELECT has_schema_privilege('dg_app', 'df', 'USAGE') INTO still_has_usage; IF still_has_usage THEN RAISE EXCEPTION 'TEST 8 FAILED: dg_app still has USAGE on schema df after revoke_usage'; END IF; RAISE NOTICE 'TEST 8 PASSED: dg_app lost USAGE on schema df (access gate closed)'; END $$; RESET SESSION AUTHORIZATION; -- === Test 9: app_role can no longer use pg_durable === SET SESSION AUTHORIZATION dg_app; DO $$ BEGIN BEGIN PERFORM df.start(df.sql('SELECT 1 AS should_not_run_after_revoke'), 'dg-app-after-revoke'); RAISE EXCEPTION 'SECURITY FAILURE: revoked app role was able to call df.start()'; EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'TEST 9 PASSED: revoked app role blocked from df.start()'; WHEN OTHERS THEN IF SQLERRM ILIKE '%permission denied%' THEN RAISE NOTICE 'TEST 9 PASSED: revoked app role blocked from df.start() (%)', SQLERRM; ELSE RAISE EXCEPTION 'TEST 9 UNEXPECTED ERROR: %', SQLERRM; END IF; END; END $$; RESET SESSION AUTHORIZATION; -- === Test 10: Self-revoke is harmless (PostgreSQL built-in protection) === -- We don't have an explicit self-revoke guard. None is needed: PostgreSQL's -- REVOKE only removes grants made by the current role, and dg_admin's -- privileges were granted by postgres (not by dg_admin itself). So a -- self-revoke attempt is a no-op and dg_admin retains its access. SET SESSION AUTHORIZATION dg_admin; DO $$ DECLARE still_has_usage BOOLEAN; BEGIN -- Attempt to revoke own access; PostgreSQL leaves it intact. PERFORM df.revoke_usage('dg_admin'); SELECT has_schema_privilege('dg_admin', 'df', 'USAGE') INTO still_has_usage; IF NOT still_has_usage THEN RAISE EXCEPTION 'TEST 10 FAILED: dg_admin lost USAGE after self-revoke (expected PostgreSQL to keep it)'; END IF; RAISE NOTICE 'TEST 10 PASSED: self-revoke is a no-op; dg_admin retains access (built into PostgreSQL)'; END $$; RESET SESSION AUTHORIZATION; -- === Cleanup === -- Revoke admin privileges (as superuser) SELECT df.revoke_usage('dg_admin'); DO $cleanup$ BEGIN PERFORM pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename IN ('dg_admin', 'dg_app', 'dg_delegate_target', 'dg_http_target') AND pid <> pg_backend_pid(); DROP OWNED BY dg_admin; DROP OWNED BY dg_app; DROP OWNED BY dg_delegate_target; DROP OWNED BY dg_http_target; DROP ROLE dg_admin; DROP ROLE dg_app; DROP ROLE dg_delegate_target; DROP ROLE dg_http_target; END $cleanup$; -- === Fresh-install surface check (issue #110): df.debug_connection() removed === -- v0.2.4 drops df.debug_connection() from the SQL surface. On a fresh install it -- is never created (its #[pg_extern] is annotated sql = false). Assert it is -- absent so a future change that re-emits the function is caught here. DO $surface$ BEGIN IF to_regprocedure('df.debug_connection()') IS NOT NULL THEN RAISE EXCEPTION 'TEST FAILED: df.debug_connection() should not exist on a fresh install (#110)'; END IF; RAISE NOTICE 'SURFACE CHECK PASSED: df.debug_connection() absent on fresh install'; END $surface$; SELECT 'TEST PASSED: 18_delegated_grants' AS result;