From 8d1400eb8b2c43a5205e9767c8308625f0a802a4 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 4 Aug 2026 00:41:33 +0000 Subject: [PATCH] feat(defaults): drop the schema-wide public grants, add verify/revert/tests The module revoked PUBLIC's database and function privileges and then handed schema public straight back to authenticated, anonymous and administrator, reopening as a blanket role grant what the revoke had just closed. Schema public holds only extension functions (which keep PUBLIC=X from before the revoke) and the pgpm-verify deploy helpers, so the grants added redundant ACL entries and one real hole. A function in public now needs an explicit grant. verify/ and revert/ were still stubs: verify now proves the four properties the deploy claims (no PUBLIC database CREATE, no schema public CREATE, a database-wide function default for the owner that excludes PUBLIC, no schema-wide default in public), and revert restores PostgreSQL's out-of-the-box PUBLIC privileges. Tests cover the fail-closed grant behavior and the ordering requirement: an extension created after this module has no PUBLIC execution, so the module must be deployed last. --- .../__snapshots__/defaults.test.ts.snap | 2 +- packages/defaults/__tests__/defaults.test.ts | 58 ++++++++++++++++++ packages/defaults/deploy/defaults/public.sql | 8 ++- packages/defaults/revert/defaults/public.sql | 13 +++- .../sql/pgpm-defaults--0.35.0.bundle.tar.gz | Bin 1240 -> 1881 bytes .../defaults/sql/pgpm-defaults--0.35.0.sql | 7 +-- packages/defaults/verify/defaults/public.sql | 50 ++++++++++++++- 7 files changed, 126 insertions(+), 12 deletions(-) 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 d7c5d5c058d75ddf57ced48f1da34df8584643da..3b368c057f715d7a2f948f04c1ac055eb06c2c74 100644 GIT binary patch literal 1881 zcmV-f2d4NRiwFP!000021MOFBliD~E&gcA!E;cD{D1!LysY6;^>s3njClHI!_ zH6=@J?8HdK67y19`R`Zq3j-5obGg)|@*(Yj}br>VQPaVgj%y+rxN1cGWzU{drFm^u~tBrL=i{n03w?fmK z)>VnX8EIQ>YayPlbNL>bKHEF}LOk8%>HRr?g}CQG*cS0yR<}jS-0JoS>3QT|pgyFU zCGfZM`KuRMj07FTsAK;fegVok+{dBtl}Vv!3;Y|uz@8@H zjYeUPybw&TRwy)_LGuBf=Jte_6;MXmYCB%rYA(b|@=b=9YpN5&8Y6p>r4di_XL|;l z3z6f;O`4-BHp|va^)WjsbgnQ@Ch;L$Yd)gjC|Fu9f43{i*Yc$#PjgQaD2KD=M#AHWFgMO!KgnC&FR#~3^d;8(@M$A&B9SQn45n9W#%3=r{UYl zZz1$YBbbcgyg%EH}kN1569;V@#M+rLbO^ip3K7@a4DJ^Xr?g-oumq=0)f|{Sb{66AQcJbsV-#^ zCiE(m`9kzZ^Kc61;YI(=Xb!KZ!?(jx_#&J^<$1mDWDFN?#)J8AGM?@Fe!38q=3pB3 zOU2pXW%#NO6`K3X#$Q=E)w)hOLc9@78`3UwDzt(Gb8N$`(5P)BGUE!Q$ZmI2qt)6j zR&aEkq$~kO^Zd5j?(7eUbGd>DW0@o91R=*O`2m4O@(PcE3T|c#OFKM=l0hwG< zcOLeX<5x)8e@zpKNH2DVKcYP*~|md#j9Y}-S}w_W0U zk?jO=V0Ak-Awdw?l=CR)S`pgB4VY&!(x{An+GbmB4yq4lTO~V~ZPP;bg={NAw4ZOq zp{J;{G{xw(RC<}?>>s110=dyG8MkzTtw`QffrfRSUZoi>QMDK1#k4=3KS;;<Ln=HaW?lWBkY&-+zkyW6+ah%Yk6VgU;xj6Kq|ZPH;j zw=G6&mv*olV>ht57j$FZ?Na7NB%)E=LF(9+?R&oKq4zms%0xQMm`Zk#F>lKR{A#qFe#L#LW4{>^v=NAjnMD{5~{YqUT*1h)GW;$ME?YuCCQq{Fn<}2 z%Lq^V!3^Xf>DxGFoRmduCN&&SsczSS)GOXis7+w^|+`3v8(1VWd{QGA2 z)NU1Se(p`H1@i>Arzi`06|P>LoU4t}rWkIopqi&UrB7AXs@AnE3g#vNDcUeGg}*4@ zGQ*Z)a*h0?zCmzg5>;`(omSSUfZ;a(uV|hc0$j^Y#vwy`RTprn6U^(gZiJQ_ zmLP9eaE~XZ5)5ba861@umI)1{d@Js^_42ar!6?g0!S64_X;`4aGSlj)HD#u2vPv;P ze|%nvW%OR(u8ZC*ZtjX}N|^4d*UNEf5a{&`iO;IFX2vY}%P0$ZI7mB{(_pxBpUP{2 z9HQGjW*RpRa`Gzy0)7INWq7mBB*!D(E{{T4b_{e6z4n*zYkvVj+OA8$*}a;uueAl@ zvbIyvwE0DF52O&i2X{^R8unf4AI_`&2FSEEDVm<*aPL&xrKSbUdUb=M6RInF0Cwi(|&uyy4sh`tqkWwgH`j%vT>qz0~Cw7(*VyO-~DG8#Sa4}N>_ z`t7iwYsZ8+#Isz#8_}3WF6%JQjpGh=Xb`wo>2y6#e9I+%;Cqhc5sdFX#_$H& z{)Jv$+ve^KCl`asdH4f z+;%<7=a$>`J?3NJbQtf()N$gV+jT9EV-R~@fDF3~smB9)*ML2)f8Ra+^!U-w@pJqf TKgZAUgOC3JAtTph02%-Q?(w@p literal 1240 zcmV;}1Sk6+iwFP!000026YW-QZ`w!@&gcA!6-`7d1-ff{*LJv`O2Fx%5~zrii|$-i z`!XAxnl-UaE>YG0ezJiOC~dF0YgOqoA6R>4_Su=~pZNg2gAuno&sC-ALrqgV~ zi*v~GI5o9HR3o*6dBpGul#XdB90i)sk|JLQWd^(e^?O{5%bb^G(pCOkrWxF)jJdgl zPC7H?ab)3DFiq0CVWuFf9il%gXXT#bKcCJ-63bfUf4kfhU(Rc;VOMdN3FcErTb9z2 zaw#97hEJIkmgAak;!LM$YwcYD;Gkm+nj4HzgN_UrLLAD z-p1=vk6;uS8PB)BeHcm7yL-|r-}~68Ib0PxSVi*d@_uTK2I^L^b+!*T^--i~dKsC> z90jj>gH~j^L$tRSnQqYOw?{$QU4EUQ0&a`eBB}J92K}HDmQM)Hzuc$wQE)zd9iVo< zkA?%(ZHMin_Gy6jd+2yHJV9^Hj{3b${Q${XmVqhWsghfs!_#NaP;U?p%Y#}aWN;C5 z&ca~v9t^sX+1qPHrqMuyVHg}Dm702icv3)yigBDHo+AT)pfuP)9#1C2z7(w_E|qfZ$`cIUO#vhoT5eL%X5bVbbL1GguUV5^kMC-$Sg8Dqo6%c zJnj4zoV3w`&GqD0qxK+th+0y!Lhfzu4u^=(iZPfXmgP`8K-{F}Zj#RO1H{!NHgR5L zyhyXw7n<3G-BQP@hHpBuGdwxzg{{au)Z1z%YD+uJ1q@g_N-@nbXyLk!Z@HdLZDnIy z2wzbk+;Spf+=Qugj3~Njs=d=TmT5=5oL2iJgTS=8ic^qcPW*Q z=P^aFQlIudA4A)FU&OZdeqP1C(tB#Y)OiJ8>LE_e0}4jNe*dW5dHs13Y=;vL5;I_$ zVH<2NmFsKG!F4RfiRTi>lFS0zWx{hjmw7^QAwUz$CE`;OR@ed)Nw}{tNY|xOVn(pE zJqldgE)h%-ssSrZw55r1T$@PC!&pmBsjq>q1})rzT&HzGUOD-K;c;i!4SvD_xU8(Y ztbaSn&?qfPn<+ue`5JCF8gkM?N)Kl>Yjtvg5n5&!@Y CMq>m3 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;