diff --git a/packages/defaults/__tests__/__snapshots__/defaults.test.ts.snap b/packages/defaults/__tests__/__snapshots__/defaults.test.ts.snap index fe5ca668..000d52fe 100644 --- a/packages/defaults/__tests__/__snapshots__/defaults.test.ts.snap +++ b/packages/defaults/__tests__/__snapshots__/defaults.test.ts.snap @@ -6,7 +6,7 @@ exports[`defaults security configurations configuration verification should crea { "current_user": "postgres", "database_name": "test-database", - "default_func_acl_count": "2", + "default_func_acl_count": "1", "public_db_connect": false, "public_db_create": false, "public_schema_create": false, diff --git a/packages/defaults/__tests__/defaults.test.ts b/packages/defaults/__tests__/defaults.test.ts index 9bcdcf54..15a2a27d 100644 --- a/packages/defaults/__tests__/defaults.test.ts +++ b/packages/defaults/__tests__/defaults.test.ts @@ -206,6 +206,64 @@ describe('defaults security configurations', () => { }); }); + describe('schema public function grants', () => { + beforeEach(async () => { + await pg.any(` + CREATE FUNCTION public_grant_probe() + RETURNS text AS $$ + BEGIN + RETURN 'probe'; + END; + $$ LANGUAGE plpgsql; + `); + }); + + it('grants no role schema-wide execution, not even authenticated', async () => { + const [privileges] = await pg.any(` + SELECT + has_function_privilege('authenticated', 'public_grant_probe()', 'execute') as authenticated_can_execute, + has_function_privilege('administrator', 'public_grant_probe()', 'execute') as administrator_can_execute, + has_function_privilege('anonymous', 'public_grant_probe()', 'execute') as anonymous_can_execute + `); + + // A function in schema public is reachable only by an explicit grant. + expect(privileges.authenticated_can_execute).toBe(false); + expect(privileges.administrator_can_execute).toBe(false); + expect(privileges.anonymous_can_execute).toBe(false); + }); + + it('leaves an explicit grant as the only way in', async () => { + await pg.any(`GRANT EXECUTE ON FUNCTION public_grant_probe() TO authenticated`); + + const [privileges] = await pg.any(` + SELECT + has_function_privilege('authenticated', 'public_grant_probe()', 'execute') as authenticated_can_execute, + has_function_privilege('anonymous', 'public_grant_probe()', 'execute') as anonymous_can_execute + `); + + expect(privileges.authenticated_can_execute).toBe(true); + expect(privileges.anonymous_can_execute).toBe(false); + }); + }); + + describe('extension ordering', () => { + // The revoke is prospective, so an extension created after this module + // loses PUBLIC's EXECUTE and its functions become uncallable: pgpm-defaults + // must be the last requirement of any deployment that installs extensions. + it('leaves an extension created after it without PUBLIC execution', async () => { + await pg.any(`CREATE EXTENSION IF NOT EXISTS citext`); + + const [privileges] = await pg.any(` + SELECT + has_function_privilege('public', 'citext_eq(citext,citext)', 'execute') as public_can_execute, + has_function_privilege('anonymous', 'citext_eq(citext,citext)', 'execute') as anonymous_can_execute + `); + + expect(privileges.public_can_execute).toBe(false); + expect(privileges.anonymous_can_execute).toBe(false); + }); + }); + describe('privilege inheritance', () => { it('should verify that new schemas inherit secure defaults', async () => { // Create a new schema diff --git a/packages/defaults/deploy/defaults/public.sql b/packages/defaults/deploy/defaults/public.sql index 13eaa1c1..eecc1e05 100644 --- a/packages/defaults/deploy/defaults/public.sql +++ b/packages/defaults/deploy/defaults/public.sql @@ -13,7 +13,9 @@ $$; -- NOTE: don't alter this as new schemas inherit this behavior ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; REVOKE CREATE ON SCHEMA public FROM PUBLIC; -GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO authenticated, anonymous, administrator; -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT EXECUTE ON FUNCTIONS TO authenticated, anonymous, administrator; +-- No schema-wide function grants here. Schema public holds only extension +-- functions (which carry PUBLIC=X from before the revoke above) and the +-- pgpm-verify deploy-time helpers, so granting a role every function in it +-- adds nothing but a blanket grant. Application function grants are declared +-- per function. COMMIT; diff --git a/packages/defaults/revert/defaults/public.sql b/packages/defaults/revert/defaults/public.sql index 79b4d247..e099cd96 100644 --- a/packages/defaults/revert/defaults/public.sql +++ b/packages/defaults/revert/defaults/public.sql @@ -2,6 +2,17 @@ BEGIN; --- XXX Add DDLs here. +-- Restores PostgreSQL's out-of-the-box PUBLIC privileges. +GRANT CREATE ON SCHEMA public TO PUBLIC; +ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC; +DO $$ +DECLARE + sql text; +BEGIN + SELECT + format('GRANT CONNECT, TEMPORARY ON DATABASE %I TO PUBLIC', current_database()) INTO sql; + EXECUTE sql; +END +$$; COMMIT; diff --git a/packages/defaults/sql/pgpm-defaults--0.35.0.bundle.tar.gz b/packages/defaults/sql/pgpm-defaults--0.35.0.bundle.tar.gz index d7c5d5c0..3b368c05 100644 Binary files a/packages/defaults/sql/pgpm-defaults--0.35.0.bundle.tar.gz and b/packages/defaults/sql/pgpm-defaults--0.35.0.bundle.tar.gz differ diff --git a/packages/defaults/sql/pgpm-defaults--0.35.0.sql b/packages/defaults/sql/pgpm-defaults--0.35.0.sql index ee8b7591..7ff67127 100644 --- a/packages/defaults/sql/pgpm-defaults--0.35.0.sql +++ b/packages/defaults/sql/pgpm-defaults--0.35.0.sql @@ -12,9 +12,4 @@ $EOFCODE$; ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC RESTRICT; -REVOKE CREATE ON SCHEMA public FROM PUBLIC RESTRICT; - -GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO authenticated, anonymous, administrator; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT EXECUTE ON FUNCTIONS TO authenticated, anonymous, administrator; \ No newline at end of file +REVOKE CREATE ON SCHEMA public FROM PUBLIC RESTRICT; \ No newline at end of file diff --git a/packages/defaults/verify/defaults/public.sql b/packages/defaults/verify/defaults/public.sql index 084339f2..138da789 100644 --- a/packages/defaults/verify/defaults/public.sql +++ b/packages/defaults/verify/defaults/public.sql @@ -2,6 +2,54 @@ BEGIN; --- XXX Add verifications here. +DO $$ +DECLARE + public_grantee constant oid := 0; +BEGIN + IF has_database_privilege('public', current_database(), 'CREATE') THEN + RAISE EXCEPTION 'PUBLIC still holds CREATE on database %', current_database(); + END IF; + + IF has_schema_privilege('public', 'public', 'CREATE') THEN + RAISE EXCEPTION 'PUBLIC still holds CREATE on schema public'; + END IF; + + -- The database-wide default: PUBLIC must not inherit EXECUTE on functions + -- created from here on. Only the role-scoped, schema-less form does this; + -- an IN SCHEMA variant would leave every other schema untouched. + IF NOT EXISTS ( + SELECT 1 + FROM pg_default_acl + WHERE defaclnamespace = 0 + AND defaclobjtype = 'f' + AND current_user::regrole::oid = defaclrole + ) THEN + RAISE EXCEPTION 'no database-wide default privileges for functions owned by %', current_user; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_default_acl d, aclexplode(d.defaclacl) a + WHERE d.defaclnamespace = 0 + AND d.defaclobjtype = 'f' + AND a.grantee = public_grantee + AND a.privilege_type = 'EXECUTE' + ) THEN + RAISE EXCEPTION 'PUBLIC still holds a database-wide EXECUTE default on functions'; + END IF; + + -- Schema public carries no schema-wide function default at all: a role that + -- needs a function there is granted that function. + IF EXISTS ( + SELECT 1 + FROM pg_default_acl d, aclexplode(d.defaclacl) a + WHERE d.defaclnamespace = 'public'::regnamespace + AND d.defaclobjtype = 'f' + AND a.privilege_type = 'EXECUTE' + ) THEN + RAISE EXCEPTION 'schema public has a schema-wide EXECUTE default on functions'; + END IF; +END +$$; ROLLBACK;